From e7699c3369c78eed6bb420f7c935696816bb9afc Mon Sep 17 00:00:00 2001 From: Yasir Date: Thu, 11 Dec 2025 18:44:40 +0300 Subject: [PATCH 001/189] feat: Add initial implementation of audit trails module --- audit_trails/Move.toml | 9 +++++ audit_trails/sources/audit_trails.move | 51 ++++++++++++++++++++++++++ audit_trails/sources/capabilities.move | 8 ++++ audit_trails/sources/permissions.move | 5 +++ 4 files changed, 73 insertions(+) create mode 100644 audit_trails/Move.toml create mode 100644 audit_trails/sources/audit_trails.move create mode 100644 audit_trails/sources/capabilities.move create mode 100644 audit_trails/sources/permissions.move diff --git a/audit_trails/Move.toml b/audit_trails/Move.toml new file mode 100644 index 00000000..c174ac50 --- /dev/null +++ b/audit_trails/Move.toml @@ -0,0 +1,9 @@ +[package] +name = "audit_trails_poc" +edition = "2024.beta" + +[dependencies] +Iota = { git = "https://github.com/iotaledger/iota.git", subdir = "crates/iota-framework/packages/iota-framework", rev = "v1.11.0-rc" } + +[addresses] +audit_trails_poc = "0x0" diff --git a/audit_trails/sources/audit_trails.move b/audit_trails/sources/audit_trails.move new file mode 100644 index 00000000..24b8567d --- /dev/null +++ b/audit_trails/sources/audit_trails.move @@ -0,0 +1,51 @@ +// Copyright (c) 2025 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +/// Audit Trails - Tamper-proof sequential record chains with RBAC +module audit_trails_poc::audit_trails; + +use iota::clock::Clock; +use iota::vec_map::VecMap; +use iota::vec_set::VecSet; +use std::string::String; + +// ===== Core Structures ===== + +/// Controls when records can be deleted +public struct LockingConfig has copy, drop, store { + time_window_seconds: Option, + count_window: Option, +} + +/// Immutable trail metadata (set at creation) +public struct TrailMetadata has store { + name: Option, + description: Option, +} + +public struct Permission has copy, drop, store {} + +/// Shared audit trail object +public struct AuditTrail has key, store { + id: UID, + locking_config: LockingConfig, + permissions: VecMap>, + immutable_metadata: TrailMetadata, + updatable_metadata: Option, + issued_capabilities: VecSet, + creator: address, + created_at: u64, + record_count: u64, +} + +/// A single record in the audit trail +public struct Record has key, store { + id: UID, + trail_id: ID, + stored_data: D, + record_metadata: Option, + previous_record_id: Option, + sequence_number: u64, + added_by: address, + added_at: u64, +} diff --git a/audit_trails/sources/capabilities.move b/audit_trails/sources/capabilities.move new file mode 100644 index 00000000..079302ab --- /dev/null +++ b/audit_trails/sources/capabilities.move @@ -0,0 +1,8 @@ +// Copyright (c) 2025 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +/// Role-based access control capabilities for audit trails +module audit_trails_poc::capabilities; + +use iota::clock::Clock; +use std::string::String; diff --git a/audit_trails/sources/permissions.move b/audit_trails/sources/permissions.move new file mode 100644 index 00000000..8ff6d96a --- /dev/null +++ b/audit_trails/sources/permissions.move @@ -0,0 +1,5 @@ +// Copyright (c) 2025 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +/// Permission system for role-based access control +module audit_trails_poc::permissions; From c4f79fba04e60087a3335bbe2aedb2413d9ec622 Mon Sep 17 00:00:00 2001 From: Yasir Date: Thu, 11 Dec 2025 18:46:10 +0300 Subject: [PATCH 002/189] refactor: Rename module and package from audit_trails_poc to audit_trails for consistency --- audit_trails/Move.toml | 5 ++--- audit_trails/sources/audit_trails.move | 2 +- audit_trails/sources/capabilities.move | 2 +- audit_trails/sources/permissions.move | 2 +- 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/audit_trails/Move.toml b/audit_trails/Move.toml index c174ac50..19af91a8 100644 --- a/audit_trails/Move.toml +++ b/audit_trails/Move.toml @@ -1,9 +1,8 @@ [package] -name = "audit_trails_poc" +name = "audit_trails" edition = "2024.beta" [dependencies] -Iota = { git = "https://github.com/iotaledger/iota.git", subdir = "crates/iota-framework/packages/iota-framework", rev = "v1.11.0-rc" } [addresses] -audit_trails_poc = "0x0" +audit_trails = "0x0" diff --git a/audit_trails/sources/audit_trails.move b/audit_trails/sources/audit_trails.move index 24b8567d..82e1bf16 100644 --- a/audit_trails/sources/audit_trails.move +++ b/audit_trails/sources/audit_trails.move @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 /// Audit Trails - Tamper-proof sequential record chains with RBAC -module audit_trails_poc::audit_trails; +module audit_trails::audit_trails; use iota::clock::Clock; use iota::vec_map::VecMap; diff --git a/audit_trails/sources/capabilities.move b/audit_trails/sources/capabilities.move index 079302ab..23172c27 100644 --- a/audit_trails/sources/capabilities.move +++ b/audit_trails/sources/capabilities.move @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 /// Role-based access control capabilities for audit trails -module audit_trails_poc::capabilities; +module audit_trails::capabilities; use iota::clock::Clock; use std::string::String; diff --git a/audit_trails/sources/permissions.move b/audit_trails/sources/permissions.move index 8ff6d96a..4da4229c 100644 --- a/audit_trails/sources/permissions.move +++ b/audit_trails/sources/permissions.move @@ -2,4 +2,4 @@ // SPDX-License-Identifier: Apache-2.0 /// Permission system for role-based access control -module audit_trails_poc::permissions; +module audit_trails::permissions; From f6e7e11198bc7c197720e2322976317b423abe55 Mon Sep 17 00:00:00 2001 From: Christof Gerritsma Date: Fri, 12 Dec 2025 12:35:06 +0100 Subject: [PATCH 003/189] feat: Audit Trails_ Rename move directory and add some folders (#162) * rename `audit_trails` folder to `audit-trails-move` * Add folders for audit-trails-rs and audit_trails_wasm --- {audit_trails => audit-trails-move}/Move.toml | 0 {audit_trails => audit-trails-move}/sources/audit_trails.move | 0 {audit_trails => audit-trails-move}/sources/capabilities.move | 0 {audit_trails => audit-trails-move}/sources/permissions.move | 0 audit-trails-rs/README.md | 1 + bindings/wasm/audit_trails_wasm/README.md | 1 + 6 files changed, 2 insertions(+) rename {audit_trails => audit-trails-move}/Move.toml (100%) rename {audit_trails => audit-trails-move}/sources/audit_trails.move (100%) rename {audit_trails => audit-trails-move}/sources/capabilities.move (100%) rename {audit_trails => audit-trails-move}/sources/permissions.move (100%) create mode 100644 audit-trails-rs/README.md create mode 100644 bindings/wasm/audit_trails_wasm/README.md diff --git a/audit_trails/Move.toml b/audit-trails-move/Move.toml similarity index 100% rename from audit_trails/Move.toml rename to audit-trails-move/Move.toml diff --git a/audit_trails/sources/audit_trails.move b/audit-trails-move/sources/audit_trails.move similarity index 100% rename from audit_trails/sources/audit_trails.move rename to audit-trails-move/sources/audit_trails.move diff --git a/audit_trails/sources/capabilities.move b/audit-trails-move/sources/capabilities.move similarity index 100% rename from audit_trails/sources/capabilities.move rename to audit-trails-move/sources/capabilities.move diff --git a/audit_trails/sources/permissions.move b/audit-trails-move/sources/permissions.move similarity index 100% rename from audit_trails/sources/permissions.move rename to audit-trails-move/sources/permissions.move diff --git a/audit-trails-rs/README.md b/audit-trails-rs/README.md new file mode 100644 index 00000000..02dd6176 --- /dev/null +++ b/audit-trails-rs/README.md @@ -0,0 +1 @@ +# IOTA Audit Trails \ No newline at end of file diff --git a/bindings/wasm/audit_trails_wasm/README.md b/bindings/wasm/audit_trails_wasm/README.md new file mode 100644 index 00000000..726f73a4 --- /dev/null +++ b/bindings/wasm/audit_trails_wasm/README.md @@ -0,0 +1 @@ +# IOTA Audit Trails WASM Library \ No newline at end of file From e44917712c0dabad9681356b54363f46b399bb25 Mon Sep 17 00:00:00 2001 From: Yasir Date: Wed, 17 Dec 2025 12:32:48 +0300 Subject: [PATCH 004/189] feat: Implement audit trails with role-based access control --- audit-trails-move/Move.lock | 42 ++ audit-trails-move/sources/audit_trails.move | 621 +++++++++++++++++++- audit-trails-move/sources/capabilities.move | 31 +- audit-trails-move/sources/permissions.move | 58 ++ notarization-move/Move.history.json | 10 +- 5 files changed, 737 insertions(+), 25 deletions(-) create mode 100644 audit-trails-move/Move.lock diff --git a/audit-trails-move/Move.lock b/audit-trails-move/Move.lock new file mode 100644 index 00000000..b38c76a9 --- /dev/null +++ b/audit-trails-move/Move.lock @@ -0,0 +1,42 @@ +# @generated by Move, please check-in and do not edit manually. + +[move] +version = 3 +manifest_digest = "205525E3D4D4DF71C1144E3EE5DDD210506D20F1DB2438FC02BB2ADCE7E5BFD6" +deps_digest = "F9B494B64F0615AED0E98FC12A85B85ECD2BC5185C22D30E7F67786BB52E507C" +dependencies = [ + { id = "Iota", name = "Iota" }, + { id = "IotaSystem", name = "IotaSystem" }, + { id = "MoveStdlib", name = "MoveStdlib" }, + { id = "Stardust", name = "Stardust" }, +] + +[[move.package]] +id = "Iota" +source = { git = "https://github.com/iotaledger/iota.git", rev = "4698c6723208e052a00c74602d2c8dc0efffe5de", subdir = "crates/iota-framework/packages/iota-framework" } + +dependencies = [ + { id = "MoveStdlib", name = "MoveStdlib" }, +] + +[[move.package]] +id = "IotaSystem" +source = { git = "https://github.com/iotaledger/iota.git", rev = "4698c6723208e052a00c74602d2c8dc0efffe5de", subdir = "crates/iota-framework/packages/iota-system" } + +dependencies = [ + { id = "Iota", name = "Iota" }, + { id = "MoveStdlib", name = "MoveStdlib" }, +] + +[[move.package]] +id = "MoveStdlib" +source = { git = "https://github.com/iotaledger/iota.git", rev = "4698c6723208e052a00c74602d2c8dc0efffe5de", subdir = "crates/iota-framework/packages/move-stdlib" } + +[[move.package]] +id = "Stardust" +source = { git = "https://github.com/iotaledger/iota.git", rev = "4698c6723208e052a00c74602d2c8dc0efffe5de", subdir = "crates/iota-framework/packages/stardust" } + +dependencies = [ + { id = "Iota", name = "Iota" }, + { id = "MoveStdlib", name = "MoveStdlib" }, +] diff --git a/audit-trails-move/sources/audit_trails.move b/audit-trails-move/sources/audit_trails.move index 82e1bf16..4e5b1de7 100644 --- a/audit-trails-move/sources/audit_trails.move +++ b/audit-trails-move/sources/audit_trails.move @@ -1,51 +1,636 @@ // Copyright (c) 2025 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -/// Audit Trails - Tamper-proof sequential record chains with RBAC +/// Audit trails with role-based access control and timelock +/// +/// An audit trail is a tamper-proof, sequential chain of notarized records where each entry +/// references its predecessor, ensuring verifiable continuity and integrity. +/// +/// Records are addressed by trail_id + sequence_number (Option B design). module audit_trails::audit_trails; -use iota::clock::Clock; -use iota::vec_map::VecMap; +use audit_trails::capabilities::{Self, Capability}; +use audit_trails::permissions::{Self, Permission}; +use iota::clock::{Self, Clock}; +use iota::event; +use iota::linked_table::{Self, LinkedTable}; +use iota::vec_map::{Self, VecMap}; use iota::vec_set::VecSet; use std::string::String; +// ===== Errors ===== +/// Provided previous sequence doesn't match the trail's last sequence +const EInvalidPreviousSequence: u64 = 1; +/// Capability lacks required permission or has been revoked +const EInsufficientPermissions: u64 = 2; +/// Capability is for a different trail +const EWrongCapability: u64 = 3; +/// Role doesn't exist in the trail's permission map +const ERoleNotFound: u64 = 4; +/// Attempting to create a role that already exists +const ERoleAlreadyExists: u64 = 5; +/// Role has no permissions (must have at least one) +const EEmptyRole: u64 = 6; +/// Same permission appears multiple times in input vector +const EDuplicatePermissions: u64 = 7; +/// Capability ID not in issued whitelist (forgery attempt) +const ECapabilityNotIssued: u64 = 8; +/// Signer doesn't match the capability's issued_to address +const EUnauthorizedSigner: u64 = 9; +/// Cannot remove the setup role (prevents self-bricking) +const ECannotRemoveSetupRole: u64 = 10; +/// Cannot revoke the last capability with CapAdmin permission +const ECannotRevokeLastAdmin: u64 = 11; +/// Record not found at the given sequence number +const ERecordNotFound: u64 = 12; + // ===== Core Structures ===== -/// Controls when records can be deleted +/// Controls when records can be deleted (time OR count based) public struct LockingConfig has copy, drop, store { + /// Records locked for N seconds after creation time_window_seconds: Option, + /// Last N records are always locked count_window: Option, } -/// Immutable trail metadata (set at creation) -public struct TrailMetadata has store { +/// Metadata set at trail creation (immutable) +public struct TrailImmutableMetadata has store { name: Option, description: Option, } -public struct Permission has copy, drop, store {} +/// A single record in the audit trail (stored in LinkedTable, no ObjectID) +public struct Record has store { + /// Arbitrary data stored on-chain + stored_data: D, + /// Optional metadata for this specific record + record_metadata: Option, + /// Position in the trail (0-indexed, never reused) + sequence_number: u64, + /// Who added this record + added_by: address, + /// When this record was added (milliseconds) + added_at: u64, +} -/// Shared audit trail object -public struct AuditTrail has key, store { +/// Shared audit trail object with role-based access control +/// Records are stored in a LinkedTable and addressed by sequence number +public struct AuditTrail has key, store { id: UID, + /// Address that created this trail + creator: address, + /// Creation timestamp (milliseconds) + created_at: u64, + /// Total records ever added (also serves as next sequence number) + record_count: u64, + /// Records stored by sequence number (0-indexed) + records: LinkedTable>, + /// Deletion locking rules locking_config: LockingConfig, + /// Role name → set of permissions permissions: VecMap>, - immutable_metadata: TrailMetadata, + /// Set at creation, cannot be changed + immutable_metadata: TrailImmutableMetadata, + /// Can be updated by holders of MetadataUpdate permission updatable_metadata: Option, + /// Whitelist of all issued capability IDs issued_capabilities: VecSet, +} + +// ===== Events ===== + +/// Emitted when a new trail is created +public struct AuditTrailCreated has copy, drop { + trail_id: ID, creator: address, - created_at: u64, - record_count: u64, + timestamp: u64, + has_initial_record: bool, } -/// A single record in the audit trail -public struct Record has key, store { - id: UID, +/// Emitted when a record is added to the trail +/// Records are identified by trail_id + sequence_number +public struct RecordAdded has copy, drop { trail_id: ID, + sequence_number: u64, + added_by: address, + timestamp: u64, +} + +/// Emitted when a new role is defined +public struct RoleCreated has copy, drop { + trail_id: ID, + role: String, + created_by: address, +} + +/// Emitted when role permissions are modified +public struct RoleUpdated has copy, drop { + trail_id: ID, + role: String, + updated_by: address, +} + +/// Emitted when a role is deleted +public struct RoleRemoved has copy, drop { + trail_id: ID, + role: String, + removed_by: address, +} + +/// Emitted when a capability is revoked (removed from whitelist) +public struct CapabilityRevoked has copy, drop { + trail_id: ID, + capability_id: ID, + revoked_by: address, + timestamp: u64, +} + +/// Emitted when a revoked capability is reinstated (re-added to whitelist) +public struct CapabilityReinstated has copy, drop { + trail_id: ID, + capability_id: ID, + reinstated_by: address, + timestamp: u64, +} + +/// Emitted when a trail is deleted +public struct AuditTrailDeleted has copy, drop {} + +// ===== Constructors ===== + +public fun new_locking_config( + time_window_seconds: Option, + count_window: Option, +): LockingConfig { + LockingConfig { time_window_seconds, count_window } +} + +public fun new_trail_metadata( + name: Option, + description: Option, +): TrailImmutableMetadata { + TrailImmutableMetadata { name, description } +} + +// ===== Trail Creation ===== + +/// Create a new audit trail with optional initial record +public fun create_audit_trail( + initial_data: Option, + initial_record_metadata: Option, + locking_config: LockingConfig, + trail_metadata: TrailImmutableMetadata, + updatable_metadata: Option, + clock: &Clock, + ctx: &mut TxContext, +): ID { + let creator = tx_context::sender(ctx); + let timestamp = clock::timestamp_ms(clock); + + let trail_id = object::new(ctx); + let trail_id_inner = object::uid_to_inner(&trail_id); + + let mut records = linked_table::new>(ctx); + let mut record_count = 0; + let has_initial_record = initial_data.is_some(); + + if (initial_data.is_some()) { + let record = Record { + stored_data: initial_data.destroy_some(), + record_metadata: initial_record_metadata, + sequence_number: 0, + added_by: creator, + added_at: timestamp, + }; + + linked_table::push_back(&mut records, 0, record); + record_count = 1; + + event::emit(RecordAdded { + trail_id: trail_id_inner, + sequence_number: 0, + added_by: creator, + timestamp, + }); + } else { + initial_data.destroy_none(); + }; + + // TODO: Initialize setup role with admin permissions (bootstrap) + // The creator should receive a setup capability with PermissionAdmin + CapAdmin + // to configure roles and issue capabilities to other users. + // + + let trail = AuditTrail { + id: trail_id, + creator, + created_at: timestamp, + record_count, + records, + locking_config, + permissions: vec_map::empty(), + immutable_metadata: trail_metadata, + updatable_metadata, + issued_capabilities: iota::vec_set::empty(), + }; + + transfer::share_object(trail); + + event::emit(AuditTrailCreated { + trail_id: trail_id_inner, + creator, + timestamp, + has_initial_record, + }); + + trail_id_inner +} + +// ===== Record Operations ===== + +/// Add a record to the trail +/// +/// Validates capability permissions before allowing the operation. +/// Records are added sequentially. Use expected_sequence for optimistic concurrency. +public fun add_record( + trail: &mut AuditTrail, + cap: &Capability, stored_data: D, record_metadata: Option, - previous_record_id: Option, + expected_sequence: Option, + clock: &Clock, + ctx: &mut TxContext, +) { + check_permission(trail, cap, &permissions::record_add(), ctx); + + let caller = tx_context::sender(ctx); + let timestamp = clock::timestamp_ms(clock); + let trail_id = object::uid_to_inner(&trail.id); + + // Validate expected sequence for optimistic concurrency + if (expected_sequence.is_some()) { + let expected = *expected_sequence.borrow(); + assert!(expected == trail.record_count, EInvalidPreviousSequence); + }; + + let sequence_number = trail.record_count; + + let record = Record { + stored_data, + record_metadata, + sequence_number, + added_by: caller, + added_at: timestamp, + }; + + linked_table::push_back(&mut trail.records, sequence_number, record); + trail.record_count = trail.record_count + 1; + + event::emit(RecordAdded { + trail_id, + sequence_number, + added_by: caller, + timestamp, + }); +} + +// ===== Role & Permission Management ===== + +/// Create a new role with specific permissions +public fun create_role( + trail: &mut AuditTrail, + cap: &Capability, + role_name: String, + permissions_vec: vector, + ctx: &mut TxContext, +) { + check_permission(trail, cap, &permissions::permission_admin(), ctx); + assert!(!trail.permissions.contains(&role_name), ERoleAlreadyExists); + + validate_permissions(&permissions_vec); + + let perms = permissions::from_vec(permissions_vec); + vec_map::insert(&mut trail.permissions, role_name, perms); + + event::emit(RoleCreated { + trail_id: object::uid_to_inner(&trail.id), + role: role_name, + created_by: tx_context::sender(ctx), + }); +} + +/// Update permissions for an existing role +public fun update_role( + trail: &mut AuditTrail, + cap: &Capability, + role_name: String, + permissions_vec: vector, + ctx: &mut TxContext, +) { + check_permission(trail, cap, &permissions::permission_admin(), ctx); + assert!(trail.permissions.contains(&role_name), ERoleNotFound); + + validate_permissions(&permissions_vec); + + // TODO: Implement role update logic + + event::emit(RoleUpdated { + trail_id: object::uid_to_inner(&trail.id), + role: role_name, + updated_by: tx_context::sender(ctx), + }); +} + +/// Remove a role from the trail +public fun remove_role( + trail: &mut AuditTrail, + cap: &Capability, + role_name: String, + ctx: &mut TxContext, +) { + check_permission(trail, cap, &permissions::permission_admin(), ctx); + assert!(trail.permissions.contains(&role_name), ERoleNotFound); + + // Protect setup role from removal to prevent self-bricking + assert!(role_name != std::string::utf8(b"setup"), ECannotRemoveSetupRole); + + // TODO: Implement role removal logic + // vec_map::remove(&mut trail.permissions, &role_name); + + event::emit(RoleRemoved { + trail_id: object::uid_to_inner(&trail.id), + role: role_name, + removed_by: tx_context::sender(ctx), + }); +} + +/// Validate permissions vector for duplicates and emptiness +fun validate_permissions(permissions_vec: &vector) { + assert!(!permissions_vec.is_empty(), EEmptyRole); + + let len = permissions_vec.length(); + let mut i = 0; + while (i < len) { + let perm = &permissions_vec[i]; + let mut j = i + 1; + while (j < len) { + assert!(perm != &permissions_vec[j], EDuplicatePermissions); + j = j + 1; + }; + i = i + 1; + }; +} + +/// Internal permission check (validates cap, role, permission, and signer) +fun check_permission( + trail: &AuditTrail, + cap: &Capability, + _required: &Permission, + _ctx: &TxContext, +) { + let cap_id = capabilities::cap_id(cap); + + // Verify capability is in whitelist (not issued = revoked or forged) + assert!(trail.issued_capabilities.contains(&cap_id), ECapabilityNotIssued); + + // TODO: Implement full permission checks once capabilities have role info + // 1. Verify capability is for this trail + // 2. Verify signer matches the capability holder + // 3. Verify role exists + // 4. Verify role has the required permission +} + +/// Check if a role has CapAdmin permission +fun role_has_cap_admin(trail: &AuditTrail, role: &String): bool { + if (!trail.permissions.contains(role)) { + return false + }; + let role_perms = vec_map::get(&trail.permissions, role); + permissions::has_permission(role_perms, &permissions::cap_admin()) +} + +// ===== Capability Management ===== + +/// Issue a new capability with a specific role +public fun issue_capability( + trail: &mut AuditTrail, + cap: &Capability, + role: String, + recipient: address, + _clock: &Clock, + ctx: &mut TxContext, +) { + check_permission(trail, cap, &permissions::cap_admin(), ctx); + assert!(trail.permissions.contains(&role), ERoleNotFound); + // TODO: Implement capability issuance logic +} + +/// Revoke a capability by ID +public fun revoke_capability( + trail: &mut AuditTrail, + cap: &Capability, + cap_id: ID, + clock: &Clock, + ctx: &mut TxContext, +) { + check_permission(trail, cap, &permissions::cap_admin(), ctx); + // TODO: Implement capability revocation logic +} + +/// Revoke multiple capabilities +public fun revoke_capabilities( + trail: &mut AuditTrail, + cap: &Capability, + cap_ids: vector, + clock: &Clock, + ctx: &mut TxContext, +) { + check_permission(trail, cap, &permissions::cap_admin(), ctx); + + // TODO: Implement capability revocation logic +} + +/// Reinstate a previously revoked capability +public fun reinstate_capability( + trail: &mut AuditTrail, + cap: &Capability, + cap_id: ID, + clock: &Clock, + ctx: &mut TxContext, +) { + check_permission(trail, cap, &permissions::cap_admin(), ctx); + + // TODO: Implement capability reinstatement logic +} + +/// Check if a capability has been revoked +public fun is_capability_revoked(trail: &AuditTrail, cap_id: ID): bool { + !trail.issued_capabilities.contains(&cap_id) +} + +// ===== Metadata & Locking Management ===== + +public fun update_metadata( + trail: &mut AuditTrail, + cap: &Capability, + new_metadata: Option, + ctx: &mut TxContext, +) { + check_permission(trail, cap, &permissions::metadata_update(), ctx); + trail.updatable_metadata = new_metadata; +} + +public fun update_locking_config( + trail: &mut AuditTrail, + cap: &Capability, + new_config: LockingConfig, + ctx: &mut TxContext, +) { + check_permission(trail, cap, &permissions::locking_update(), ctx); + trail.locking_config = new_config; +} + +/// Check if a record is locked (cannot be deleted) +public fun is_record_locked( + trail: &AuditTrail, sequence_number: u64, - added_by: address, - added_at: u64, + clock: &Clock, +): bool { + assert!(linked_table::contains(&trail.records, sequence_number), ERecordNotFound); + + let record = linked_table::borrow(&trail.records, sequence_number); + let current_time = clock::timestamp_ms(clock); + + if (trail.locking_config.time_window_seconds.is_some()) { + let time_window_ms = (*trail.locking_config.time_window_seconds.borrow() as u64) * 1000; + let record_age = current_time - record.added_at; + if (record_age < time_window_ms) return true + }; + + if (trail.locking_config.count_window.is_some()) { + let count_window = *trail.locking_config.count_window.borrow(); + let records_after = trail.record_count - sequence_number - 1; + if (records_after < count_window) return true + }; + + false +} + +public fun destroy_and_revoke_capability( + trail: &mut AuditTrail, + cap: Capability, +) { + let cap_id = capabilities::cap_id(&cap); + trail.issued_capabilities.remove(&cap_id); + capabilities::destroy_capability(cap); +} + +// ===== Query Functions ===== + +/// Get the total number of records in the trail +public fun record_count(trail: &AuditTrail): u64 { + trail.record_count +} + +/// Get the trail creator address +public fun creator(trail: &AuditTrail): address { + trail.creator +} + +/// Get the trail creation timestamp +public fun created_at(trail: &AuditTrail): u64 { + trail.created_at +} + +/// Get the trail's object ID +public fun trail_id(trail: &AuditTrail): ID { + object::uid_to_inner(&trail.id) +} + +/// Get the trail name (immutable metadata) +public fun trail_name(trail: &AuditTrail): &Option { + &trail.immutable_metadata.name +} + +/// Get the trail description (immutable metadata) +public fun trail_description(trail: &AuditTrail): &Option { + &trail.immutable_metadata.description +} + +/// Get the updatable metadata +public fun updatable_metadata(trail: &AuditTrail): &Option { + &trail.updatable_metadata +} + +/// Get the locking configuration +public fun locking_config(trail: &AuditTrail): &LockingConfig { + &trail.locking_config +} + +/// Get permissions for a specific role +public fun role_permissions( + trail: &AuditTrail, + role: &String, +): &VecSet { + vec_map::get(&trail.permissions, role) +} + +/// Check if a role exists +public fun has_role(trail: &AuditTrail, role: &String): bool { + trail.permissions.contains(role) +} + +/// Check if the trail is empty (no records) +public fun is_empty(trail: &AuditTrail): bool { + linked_table::is_empty(&trail.records) +} + +/// Get the first sequence number (0 if trail has records) +public fun first_sequence(trail: &AuditTrail): Option { + *linked_table::front(&trail.records) +} + +/// Get the last sequence number +public fun last_sequence(trail: &AuditTrail): Option { + *linked_table::back(&trail.records) +} + +// ===== Record Query Functions ===== + +/// Get a record by sequence number +public fun get_record(trail: &AuditTrail, sequence_number: u64): &Record { + assert!(linked_table::contains(&trail.records, sequence_number), ERecordNotFound); + + linked_table::borrow(&trail.records, sequence_number) +} + +/// Check if a record exists at the given sequence number +public fun has_record(trail: &AuditTrail, sequence_number: u64): bool { + linked_table::contains(&trail.records, sequence_number) +} + +/// Get the stored data from a record +public fun record_data(record: &Record): &D { + &record.stored_data +} + +/// Get the record metadata +public fun record_metadata(record: &Record): &Option { + &record.record_metadata +} + +/// Get the record sequence number +public fun record_sequence_number(record: &Record): u64 { + record.sequence_number +} + +/// Get who added the record +public fun record_added_by(record: &Record): address { + record.added_by +} + +/// Get when the record was added +public fun record_added_at(record: &Record): u64 { + record.added_at } diff --git a/audit-trails-move/sources/capabilities.move b/audit-trails-move/sources/capabilities.move index 23172c27..fe49bc5a 100644 --- a/audit-trails-move/sources/capabilities.move +++ b/audit-trails-move/sources/capabilities.move @@ -4,5 +4,32 @@ /// Role-based access control capabilities for audit trails module audit_trails::capabilities; -use iota::clock::Clock; -use std::string::String; +/// Capability granting role-based access to an audit trail +public struct Capability has key, store { + id: UID, +} + +/// Create a setup capability for trail initialization +public fun new_setup_cap(ctx: &mut TxContext): Capability { + Capability { + id: object::new(ctx), + } +} + +/// Create a new capability with a specific role +public fun new_capability(ctx: &mut TxContext): Capability { + Capability { + id: object::new(ctx), + } +} + +/// Get the capability's ID +public fun cap_id(cap: &Capability): ID { + object::uid_to_inner(&cap.id) +} + +/// Destroy a capability +public fun destroy_capability(cap: Capability) { + let Capability { id } = cap; + object::delete(id); +} diff --git a/audit-trails-move/sources/permissions.move b/audit-trails-move/sources/permissions.move index 4da4229c..f76d714d 100644 --- a/audit-trails-move/sources/permissions.move +++ b/audit-trails-move/sources/permissions.move @@ -3,3 +3,61 @@ /// Permission system for role-based access control module audit_trails::permissions; + +use iota::vec_set::{Self, VecSet}; + +public struct Permission has copy, drop, store { + value: u8, +} + +/// Create an empty permission set +public fun empty(): VecSet { + vec_set::empty() +} + +/// Add a permission to a set +public fun add(set: &mut VecSet, perm: Permission) { + vec_set::insert(set, perm); +} + +/// Create a permission set from a vector +public fun from_vec(perms: vector): VecSet { + let mut set = vec_set::empty(); + let mut i = 0; + let len = perms.length(); + while (i < len) { + vec_set::insert(&mut set, perms[i]); + i = i + 1; + }; + set +} + +/// Check if a set contains a specific permission +public fun has_permission(set: &VecSet, perm: &Permission): bool { + vec_set::contains(set, perm) +} + +/// Permission to manage roles and permissions +public fun permission_admin(): Permission { + Permission { value: 0 } +} + +/// Permission to issue/revoke capabilities +public fun cap_admin(): Permission { + Permission { value: 0 } +} + +/// Permission to add records to the trail +public fun record_add(): Permission { + Permission { value: 0 } +} + +/// Permission to update trail metadata +public fun metadata_update(): Permission { + Permission { value: 0 } +} + +/// Permission to update locking configuration +public fun locking_update(): Permission { + Permission { value: 0 } +} diff --git a/notarization-move/Move.history.json b/notarization-move/Move.history.json index fa0db1b8..8f6fee0e 100644 --- a/notarization-move/Move.history.json +++ b/notarization-move/Move.history.json @@ -1,18 +1,18 @@ { "aliases": { - "mainnet": "6364aad5", + "testnet": "2304aa97", "devnet": "e678123a", - "testnet": "2304aa97" + "mainnet": "6364aad5" }, "envs": { "e678123a": [ "0x0d88bcecde97585d50207a029a85d7ea0bacf73ab741cbaa975a6e279251033a" ], - "2304aa97": [ - "0x00412bd469b7f980227c6c574090348239852e43aa07818b315854fdd8a2d25f" - ], "6364aad5": [ "0x909ce9dcd9a5e97b7b8884fac8e018fad9dece348bf73837379b8694ff684cf3" + ], + "2304aa97": [ + "0x00412bd469b7f980227c6c574090348239852e43aa07818b315854fdd8a2d25f" ] } } \ No newline at end of file From c2b11bd63782814791a45b9a36f24a02c22ba3e8 Mon Sep 17 00:00:00 2001 From: Yasir Date: Wed, 17 Dec 2025 14:44:38 +0300 Subject: [PATCH 005/189] feat: Introduce locking and record modules for audit trails --- audit-trails-move/sources/audit_trails.move | 452 +++----------------- audit-trails-move/sources/locking.move | 103 +++++ audit-trails-move/sources/record.move | 70 +++ 3 files changed, 241 insertions(+), 384 deletions(-) create mode 100644 audit-trails-move/sources/locking.move create mode 100644 audit-trails-move/sources/record.move diff --git a/audit-trails-move/sources/audit_trails.move b/audit-trails-move/sources/audit_trails.move index 4e5b1de7..89aebd37 100644 --- a/audit-trails-move/sources/audit_trails.move +++ b/audit-trails-move/sources/audit_trails.move @@ -6,11 +6,13 @@ /// An audit trail is a tamper-proof, sequential chain of notarized records where each entry /// references its predecessor, ensuring verifiable continuity and integrity. /// -/// Records are addressed by trail_id + sequence_number (Option B design). +/// Records are addressed by trail_id + sequence_number module audit_trails::audit_trails; use audit_trails::capabilities::{Self, Capability}; +use audit_trails::locking::{Self, LockingConfig}; use audit_trails::permissions::{Self, Permission}; +use audit_trails::record::{Self, Record}; use iota::clock::{Self, Clock}; use iota::event; use iota::linked_table::{Self, LinkedTable}; @@ -19,61 +21,17 @@ use iota::vec_set::VecSet; use std::string::String; // ===== Errors ===== -/// Provided previous sequence doesn't match the trail's last sequence -const EInvalidPreviousSequence: u64 = 1; -/// Capability lacks required permission or has been revoked -const EInsufficientPermissions: u64 = 2; -/// Capability is for a different trail -const EWrongCapability: u64 = 3; -/// Role doesn't exist in the trail's permission map -const ERoleNotFound: u64 = 4; -/// Attempting to create a role that already exists -const ERoleAlreadyExists: u64 = 5; -/// Role has no permissions (must have at least one) -const EEmptyRole: u64 = 6; -/// Same permission appears multiple times in input vector -const EDuplicatePermissions: u64 = 7; -/// Capability ID not in issued whitelist (forgery attempt) -const ECapabilityNotIssued: u64 = 8; -/// Signer doesn't match the capability's issued_to address -const EUnauthorizedSigner: u64 = 9; -/// Cannot remove the setup role (prevents self-bricking) -const ECannotRemoveSetupRole: u64 = 10; -/// Cannot revoke the last capability with CapAdmin permission -const ECannotRevokeLastAdmin: u64 = 11; -/// Record not found at the given sequence number -const ERecordNotFound: u64 = 12; +#[error] +const ERecordNotFound: vector = b"Record not found at the given sequence number"; // ===== Core Structures ===== -/// Controls when records can be deleted (time OR count based) -public struct LockingConfig has copy, drop, store { - /// Records locked for N seconds after creation - time_window_seconds: Option, - /// Last N records are always locked - count_window: Option, -} - /// Metadata set at trail creation (immutable) -public struct TrailImmutableMetadata has store { +public struct TrailImmutableMetadata has copy, drop, store { name: Option, description: Option, } -/// A single record in the audit trail (stored in LinkedTable, no ObjectID) -public struct Record has store { - /// Arbitrary data stored on-chain - stored_data: D, - /// Optional metadata for this specific record - record_metadata: Option, - /// Position in the trail (0-indexed, never reused) - sequence_number: u64, - /// Who added this record - added_by: address, - /// When this record was added (milliseconds) - added_at: u64, -} - /// Shared audit trail object with role-based access control /// Records are stored in a LinkedTable and addressed by sequence number public struct AuditTrail has key, store { @@ -88,13 +46,13 @@ public struct AuditTrail has key, store { records: LinkedTable>, /// Deletion locking rules locking_config: LockingConfig, - /// Role name → set of permissions + /// Role name -> set of permissions (TODO: implement) permissions: VecMap>, /// Set at creation, cannot be changed immutable_metadata: TrailImmutableMetadata, /// Can be updated by holders of MetadataUpdate permission updatable_metadata: Option, - /// Whitelist of all issued capability IDs + /// Whitelist of all issued capability IDs (TODO: implement) issued_capabilities: VecSet, } @@ -117,55 +75,9 @@ public struct RecordAdded has copy, drop { timestamp: u64, } -/// Emitted when a new role is defined -public struct RoleCreated has copy, drop { - trail_id: ID, - role: String, - created_by: address, -} - -/// Emitted when role permissions are modified -public struct RoleUpdated has copy, drop { - trail_id: ID, - role: String, - updated_by: address, -} - -/// Emitted when a role is deleted -public struct RoleRemoved has copy, drop { - trail_id: ID, - role: String, - removed_by: address, -} - -/// Emitted when a capability is revoked (removed from whitelist) -public struct CapabilityRevoked has copy, drop { - trail_id: ID, - capability_id: ID, - revoked_by: address, - timestamp: u64, -} - -/// Emitted when a revoked capability is reinstated (re-added to whitelist) -public struct CapabilityReinstated has copy, drop { - trail_id: ID, - capability_id: ID, - reinstated_by: address, - timestamp: u64, -} - -/// Emitted when a trail is deleted -public struct AuditTrailDeleted has copy, drop {} - // ===== Constructors ===== -public fun new_locking_config( - time_window_seconds: Option, - count_window: Option, -): LockingConfig { - LockingConfig { time_window_seconds, count_window } -} - +/// Create immutable trail metadata public fun new_trail_metadata( name: Option, description: Option, @@ -176,7 +88,7 @@ public fun new_trail_metadata( // ===== Trail Creation ===== /// Create a new audit trail with optional initial record -public fun create_audit_trail( +public fun create( initial_data: Option, initial_record_metadata: Option, locking_config: LockingConfig, @@ -185,30 +97,30 @@ public fun create_audit_trail( clock: &Clock, ctx: &mut TxContext, ): ID { - let creator = tx_context::sender(ctx); + let creator = ctx.sender(); let timestamp = clock::timestamp_ms(clock); - let trail_id = object::new(ctx); - let trail_id_inner = object::uid_to_inner(&trail_id); + let trail_uid = object::new(ctx); + let trail_id = object::uid_to_inner(&trail_uid); let mut records = linked_table::new>(ctx); let mut record_count = 0; let has_initial_record = initial_data.is_some(); if (initial_data.is_some()) { - let record = Record { - stored_data: initial_data.destroy_some(), - record_metadata: initial_record_metadata, - sequence_number: 0, - added_by: creator, - added_at: timestamp, - }; + let record = record::new( + initial_data.destroy_some(), + initial_record_metadata, + 0, // sequence_number + creator, + timestamp, + ); linked_table::push_back(&mut records, 0, record); record_count = 1; event::emit(RecordAdded { - trail_id: trail_id_inner, + trail_id, sequence_number: 0, added_by: creator, timestamp, @@ -220,10 +132,9 @@ public fun create_audit_trail( // TODO: Initialize setup role with admin permissions (bootstrap) // The creator should receive a setup capability with PermissionAdmin + CapAdmin // to configure roles and issue capabilities to other users. - // let trail = AuditTrail { - id: trail_id, + id: trail_uid, creator, created_at: timestamp, record_count, @@ -238,51 +149,44 @@ public fun create_audit_trail( transfer::share_object(trail); event::emit(AuditTrailCreated { - trail_id: trail_id_inner, + trail_id, creator, timestamp, has_initial_record, }); - trail_id_inner + trail_id } // ===== Record Operations ===== /// Add a record to the trail /// -/// Validates capability permissions before allowing the operation. -/// Records are added sequentially. Use expected_sequence for optimistic concurrency. +/// Records are added sequentially with auto-assigned sequence numbers. +/// +/// TODO: Add capability parameter and permission check once implemented public fun add_record( trail: &mut AuditTrail, cap: &Capability, stored_data: D, record_metadata: Option, - expected_sequence: Option, clock: &Clock, ctx: &mut TxContext, ) { - check_permission(trail, cap, &permissions::record_add(), ctx); + // TODO: check_permission(trail, cap, &permissions::record_add(), ctx); - let caller = tx_context::sender(ctx); + let caller = ctx.sender(); let timestamp = clock::timestamp_ms(clock); let trail_id = object::uid_to_inner(&trail.id); - - // Validate expected sequence for optimistic concurrency - if (expected_sequence.is_some()) { - let expected = *expected_sequence.borrow(); - assert!(expected == trail.record_count, EInvalidPreviousSequence); - }; - let sequence_number = trail.record_count; - let record = Record { + let record = record::new( stored_data, record_metadata, sequence_number, - added_by: caller, - added_at: timestamp, - }; + caller, + timestamp, + ); linked_table::push_back(&mut trail.records, sequence_number, record); trail.record_count = trail.record_count + 1; @@ -295,238 +199,57 @@ public fun add_record( }); } -// ===== Role & Permission Management ===== - -/// Create a new role with specific permissions -public fun create_role( - trail: &mut AuditTrail, - cap: &Capability, - role_name: String, - permissions_vec: vector, - ctx: &mut TxContext, -) { - check_permission(trail, cap, &permissions::permission_admin(), ctx); - assert!(!trail.permissions.contains(&role_name), ERoleAlreadyExists); - - validate_permissions(&permissions_vec); - - let perms = permissions::from_vec(permissions_vec); - vec_map::insert(&mut trail.permissions, role_name, perms); - - event::emit(RoleCreated { - trail_id: object::uid_to_inner(&trail.id), - role: role_name, - created_by: tx_context::sender(ctx), - }); -} - -/// Update permissions for an existing role -public fun update_role( - trail: &mut AuditTrail, - cap: &Capability, - role_name: String, - permissions_vec: vector, - ctx: &mut TxContext, -) { - check_permission(trail, cap, &permissions::permission_admin(), ctx); - assert!(trail.permissions.contains(&role_name), ERoleNotFound); - - validate_permissions(&permissions_vec); - - // TODO: Implement role update logic - - event::emit(RoleUpdated { - trail_id: object::uid_to_inner(&trail.id), - role: role_name, - updated_by: tx_context::sender(ctx), - }); -} +// ===== Locking ===== -/// Remove a role from the trail -public fun remove_role( - trail: &mut AuditTrail, - cap: &Capability, - role_name: String, - ctx: &mut TxContext, -) { - check_permission(trail, cap, &permissions::permission_admin(), ctx); - assert!(trail.permissions.contains(&role_name), ERoleNotFound); - - // Protect setup role from removal to prevent self-bricking - assert!(role_name != std::string::utf8(b"setup"), ECannotRemoveSetupRole); - - // TODO: Implement role removal logic - // vec_map::remove(&mut trail.permissions, &role_name); - - event::emit(RoleRemoved { - trail_id: object::uid_to_inner(&trail.id), - role: role_name, - removed_by: tx_context::sender(ctx), - }); -} - -/// Validate permissions vector for duplicates and emptiness -fun validate_permissions(permissions_vec: &vector) { - assert!(!permissions_vec.is_empty(), EEmptyRole); - - let len = permissions_vec.length(); - let mut i = 0; - while (i < len) { - let perm = &permissions_vec[i]; - let mut j = i + 1; - while (j < len) { - assert!(perm != &permissions_vec[j], EDuplicatePermissions); - j = j + 1; - }; - i = i + 1; - }; -} - -/// Internal permission check (validates cap, role, permission, and signer) -fun check_permission( +/// Check if a record is locked (cannot be deleted) +public fun is_record_locked( trail: &AuditTrail, - cap: &Capability, - _required: &Permission, - _ctx: &TxContext, -) { - let cap_id = capabilities::cap_id(cap); - - // Verify capability is in whitelist (not issued = revoked or forged) - assert!(trail.issued_capabilities.contains(&cap_id), ECapabilityNotIssued); - - // TODO: Implement full permission checks once capabilities have role info - // 1. Verify capability is for this trail - // 2. Verify signer matches the capability holder - // 3. Verify role exists - // 4. Verify role has the required permission -} - -/// Check if a role has CapAdmin permission -fun role_has_cap_admin(trail: &AuditTrail, role: &String): bool { - if (!trail.permissions.contains(role)) { - return false - }; - let role_perms = vec_map::get(&trail.permissions, role); - permissions::has_permission(role_perms, &permissions::cap_admin()) -} - -// ===== Capability Management ===== - -/// Issue a new capability with a specific role -public fun issue_capability( - trail: &mut AuditTrail, - cap: &Capability, - role: String, - recipient: address, - _clock: &Clock, - ctx: &mut TxContext, -) { - check_permission(trail, cap, &permissions::cap_admin(), ctx); - assert!(trail.permissions.contains(&role), ERoleNotFound); - // TODO: Implement capability issuance logic -} - -/// Revoke a capability by ID -public fun revoke_capability( - trail: &mut AuditTrail, - cap: &Capability, - cap_id: ID, + sequence_number: u64, clock: &Clock, - ctx: &mut TxContext, -) { - check_permission(trail, cap, &permissions::cap_admin(), ctx); - // TODO: Implement capability revocation logic -} +): bool { + assert!(linked_table::contains(&trail.records, sequence_number), ERecordNotFound); -/// Revoke multiple capabilities -public fun revoke_capabilities( - trail: &mut AuditTrail, - cap: &Capability, - cap_ids: vector, - clock: &Clock, - ctx: &mut TxContext, -) { - check_permission(trail, cap, &permissions::cap_admin(), ctx); + let record = linked_table::borrow(&trail.records, sequence_number); + let current_time = clock::timestamp_ms(clock); - // TODO: Implement capability revocation logic + locking::is_locked( + &trail.locking_config, + sequence_number, + record::added_at(record), + trail.record_count, + current_time, + ) } -/// Reinstate a previously revoked capability -public fun reinstate_capability( +/// Update the locking configuration +/// +/// TODO: Add capability parameter and permission check once implemented +public fun update_locking_config( trail: &mut AuditTrail, cap: &Capability, - cap_id: ID, - clock: &Clock, - ctx: &mut TxContext, + new_config: LockingConfig, + _ctx: &mut TxContext, ) { - check_permission(trail, cap, &permissions::cap_admin(), ctx); - - // TODO: Implement capability reinstatement logic -} - -/// Check if a capability has been revoked -public fun is_capability_revoked(trail: &AuditTrail, cap_id: ID): bool { - !trail.issued_capabilities.contains(&cap_id) + // TODO: check_permission(trail, cap, &permissions::locking_update(), ctx); + trail.locking_config = new_config; } -// ===== Metadata & Locking Management ===== +// ===== Metadata ===== +/// Update the trail's mutable metadata +/// +/// TODO: Add capability parameter and permission check once implemented public fun update_metadata( trail: &mut AuditTrail, cap: &Capability, new_metadata: Option, - ctx: &mut TxContext, + _ctx: &mut TxContext, ) { - check_permission(trail, cap, &permissions::metadata_update(), ctx); + // TODO: check_permission(trail, cap, &permissions::metadata_update(), ctx); trail.updatable_metadata = new_metadata; } -public fun update_locking_config( - trail: &mut AuditTrail, - cap: &Capability, - new_config: LockingConfig, - ctx: &mut TxContext, -) { - check_permission(trail, cap, &permissions::locking_update(), ctx); - trail.locking_config = new_config; -} - -/// Check if a record is locked (cannot be deleted) -public fun is_record_locked( - trail: &AuditTrail, - sequence_number: u64, - clock: &Clock, -): bool { - assert!(linked_table::contains(&trail.records, sequence_number), ERecordNotFound); - - let record = linked_table::borrow(&trail.records, sequence_number); - let current_time = clock::timestamp_ms(clock); - - if (trail.locking_config.time_window_seconds.is_some()) { - let time_window_ms = (*trail.locking_config.time_window_seconds.borrow() as u64) * 1000; - let record_age = current_time - record.added_at; - if (record_age < time_window_ms) return true - }; - - if (trail.locking_config.count_window.is_some()) { - let count_window = *trail.locking_config.count_window.borrow(); - let records_after = trail.record_count - sequence_number - 1; - if (records_after < count_window) return true - }; - - false -} - -public fun destroy_and_revoke_capability( - trail: &mut AuditTrail, - cap: Capability, -) { - let cap_id = capabilities::cap_id(&cap); - trail.issued_capabilities.remove(&cap_id); - capabilities::destroy_capability(cap); -} - -// ===== Query Functions ===== +// ===== Trail Query Functions ===== /// Get the total number of records in the trail public fun record_count(trail: &AuditTrail): u64 { @@ -549,17 +272,17 @@ public fun trail_id(trail: &AuditTrail): ID { } /// Get the trail name (immutable metadata) -public fun trail_name(trail: &AuditTrail): &Option { +public fun name(trail: &AuditTrail): &Option { &trail.immutable_metadata.name } /// Get the trail description (immutable metadata) -public fun trail_description(trail: &AuditTrail): &Option { +public fun description(trail: &AuditTrail): &Option { &trail.immutable_metadata.description } /// Get the updatable metadata -public fun updatable_metadata(trail: &AuditTrail): &Option { +public fun metadata(trail: &AuditTrail): &Option { &trail.updatable_metadata } @@ -568,30 +291,17 @@ public fun locking_config(trail: &AuditTrail): &LockingConfi &trail.locking_config } -/// Get permissions for a specific role -public fun role_permissions( - trail: &AuditTrail, - role: &String, -): &VecSet { - vec_map::get(&trail.permissions, role) -} - -/// Check if a role exists -public fun has_role(trail: &AuditTrail, role: &String): bool { - trail.permissions.contains(role) -} - /// Check if the trail is empty (no records) public fun is_empty(trail: &AuditTrail): bool { linked_table::is_empty(&trail.records) } -/// Get the first sequence number (0 if trail has records) +/// Get the first sequence number (None if empty) public fun first_sequence(trail: &AuditTrail): Option { *linked_table::front(&trail.records) } -/// Get the last sequence number +/// Get the last sequence number (None if empty) public fun last_sequence(trail: &AuditTrail): Option { *linked_table::back(&trail.records) } @@ -601,7 +311,6 @@ public fun last_sequence(trail: &AuditTrail): Option { /// Get a record by sequence number public fun get_record(trail: &AuditTrail, sequence_number: u64): &Record { assert!(linked_table::contains(&trail.records, sequence_number), ERecordNotFound); - linked_table::borrow(&trail.records, sequence_number) } @@ -609,28 +318,3 @@ public fun get_record(trail: &AuditTrail, sequence_number: u public fun has_record(trail: &AuditTrail, sequence_number: u64): bool { linked_table::contains(&trail.records, sequence_number) } - -/// Get the stored data from a record -public fun record_data(record: &Record): &D { - &record.stored_data -} - -/// Get the record metadata -public fun record_metadata(record: &Record): &Option { - &record.record_metadata -} - -/// Get the record sequence number -public fun record_sequence_number(record: &Record): u64 { - record.sequence_number -} - -/// Get who added the record -public fun record_added_by(record: &Record): address { - record.added_by -} - -/// Get when the record was added -public fun record_added_at(record: &Record): u64 { - record.added_at -} diff --git a/audit-trails-move/sources/locking.move b/audit-trails-move/sources/locking.move new file mode 100644 index 00000000..c20ef502 --- /dev/null +++ b/audit-trails-move/sources/locking.move @@ -0,0 +1,103 @@ +// Copyright (c) 2025 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +/// Locking configuration for audit trail records +/// +/// Controls when records can be deleted based on time window (records locked for N seconds) +/// or count window (last N records always locked). +module audit_trails::locking; + +/// Controls when records can be deleted (time OR count based) +public struct LockingConfig has copy, drop, store { + /// Records locked for N seconds after creation + time_window_seconds: Option, + /// Last N records are always locked + count_window: Option, +} + +// ===== Constructors ===== + +/// Create a new locking configuration +/// +/// - `time_window_seconds`: Records are locked for N seconds after creation (None = no time lock) +/// - `count_window`: Last N records are always locked (None = no count lock) +public fun new(time_window_seconds: Option, count_window: Option): LockingConfig { + LockingConfig { time_window_seconds, count_window } +} + +/// Create a locking config with no restrictions +public fun none(): LockingConfig { + LockingConfig { + time_window_seconds: option::none(), + count_window: option::none(), + } +} + +/// Create a time-based locking config +public fun time_based(seconds: u64): LockingConfig { + LockingConfig { + time_window_seconds: option::some(seconds), + count_window: option::none(), + } +} + +/// Create a count-based locking config +public fun count_based(count: u64): LockingConfig { + LockingConfig { + time_window_seconds: option::none(), + count_window: option::some(count), + } +} + +// ===== Getters ===== + +/// Get the time window in seconds (if set) +public fun time_window_seconds(config: &LockingConfig): &Option { + &config.time_window_seconds +} + +/// Get the count window (if set) +public fun count_window(config: &LockingConfig): &Option { + &config.count_window +} + +// ===== Locking Logic ===== + +/// Check if a record is locked based on time window +/// +/// Returns true if the record was created within the time window +public fun is_time_locked(config: &LockingConfig, record_timestamp: u64, current_time: u64): bool { + if (config.time_window_seconds.is_none()) { + return false + }; + + let time_window_ms = (*config.time_window_seconds.borrow()) * 1000; + let record_age = current_time - record_timestamp; + record_age < time_window_ms +} + +/// Check if a record is locked based on count window +/// +/// Returns true if the record is among the last N records +public fun is_count_locked(config: &LockingConfig, sequence_number: u64, total_records: u64): bool { + if (config.count_window.is_none()) { + return false + }; + + let count_window = *config.count_window.borrow(); + + let records_after = total_records - sequence_number - 1; + records_after < count_window +} + +/// Check if a record is locked (either by time or count) +public fun is_locked( + config: &LockingConfig, + sequence_number: u64, + record_timestamp: u64, + total_records: u64, + current_time: u64, +): bool { + is_time_locked(config, record_timestamp, current_time) + || is_count_locked(config, sequence_number, total_records) +} diff --git a/audit-trails-move/sources/record.move b/audit-trails-move/sources/record.move new file mode 100644 index 00000000..8f9adf93 --- /dev/null +++ b/audit-trails-move/sources/record.move @@ -0,0 +1,70 @@ +// Copyright (c) 2025 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +/// Record module for audit trail entries +/// +/// A Record represents a single entry in an audit trail, stored in a LinkedTable +/// and addressed by trail_id + sequence_number. +module audit_trails::record; + +use std::string::String; + +/// A single record in the audit trail (stored in LinkedTable, no ObjectID) +public struct Record has store { + /// Arbitrary data stored on-chain + stored_data: D, + /// Optional metadata for this specific record + record_metadata: Option, + /// Position in the trail (0-indexed, never reused) + sequence_number: u64, + /// Who added this record + added_by: address, + /// When this record was added (milliseconds) + added_at: u64, +} + +// ===== Constructors ===== + +/// Create a new record (package-private, called by audit_trails module) +public(package) fun new( + stored_data: D, + record_metadata: Option, + sequence_number: u64, + added_by: address, + added_at: u64, +): Record { + Record { + stored_data, + record_metadata, + sequence_number, + added_by, + added_at, + } +} + +// ===== Getters ===== + +/// Get the stored data from a record +public fun data(record: &Record): &D { + &record.stored_data +} + +/// Get the record metadata +public fun metadata(record: &Record): &Option { + &record.record_metadata +} + +/// Get the record sequence number +public fun sequence_number(record: &Record): u64 { + record.sequence_number +} + +/// Get who added the record +public fun added_by(record: &Record): address { + record.added_by +} + +/// Get when the record was added (milliseconds) +public fun added_at(record: &Record): u64 { + record.added_at +} From d1d8dc5a69602b1158461243b1c21f89153ff8d8 Mon Sep 17 00:00:00 2001 From: chrisgitiota Date: Sun, 21 Dec 2025 16:30:33 +0000 Subject: [PATCH 006/189] First version of permission * First implementation of the permission module based on a `Permission` enum * Unit tests for the `Permission` enum * Renamed `AuditTrail::permissions` to `AuditTrail::roles` * Renamed all modules and type-names from plural to singular name * audit_trails -> audit_trail * permissions -> permission * capabilities -> capability --- .gitignore | 3 +- .../Move.lock | 0 .../Move.toml | 4 +- .../sources/audit_trail.move | 24 +-- .../sources/capability.move | 2 +- .../sources/locking.move | 2 +- audit-trail-move/sources/permission.move | 195 ++++++++++++++++++ .../sources/record.move | 2 +- audit-trail-move/tests/permission_tests.move | 104 ++++++++++ {audit-trails-rs => audit-trail-rs}/README.md | 0 audit-trails-move/sources/permissions.move | 63 ------ 11 files changed, 318 insertions(+), 81 deletions(-) rename {audit-trails-move => audit-trail-move}/Move.lock (100%) rename {audit-trails-move => audit-trail-move}/Move.toml (58%) rename audit-trails-move/sources/audit_trails.move => audit-trail-move/sources/audit_trail.move (93%) rename audit-trails-move/sources/capabilities.move => audit-trail-move/sources/capability.move (95%) rename {audit-trails-move => audit-trail-move}/sources/locking.move (99%) create mode 100644 audit-trail-move/sources/permission.move rename {audit-trails-move => audit-trail-move}/sources/record.move (98%) create mode 100644 audit-trail-move/tests/permission_tests.move rename {audit-trails-rs => audit-trail-rs}/README.md (100%) delete mode 100644 audit-trails-move/sources/permissions.move diff --git a/.gitignore b/.gitignore index d29419b0..1fc1232d 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,5 @@ # ignore folder created in CI for downloaded iota binaries /iota/ -/toml-cli/ \ No newline at end of file +/toml-cli/ +/audit-trail-move/build \ No newline at end of file diff --git a/audit-trails-move/Move.lock b/audit-trail-move/Move.lock similarity index 100% rename from audit-trails-move/Move.lock rename to audit-trail-move/Move.lock diff --git a/audit-trails-move/Move.toml b/audit-trail-move/Move.toml similarity index 58% rename from audit-trails-move/Move.toml rename to audit-trail-move/Move.toml index 19af91a8..a66b8700 100644 --- a/audit-trails-move/Move.toml +++ b/audit-trail-move/Move.toml @@ -1,8 +1,8 @@ [package] -name = "audit_trails" +name = "audit_trail" edition = "2024.beta" [dependencies] [addresses] -audit_trails = "0x0" +audit_trail = "0x0" diff --git a/audit-trails-move/sources/audit_trails.move b/audit-trail-move/sources/audit_trail.move similarity index 93% rename from audit-trails-move/sources/audit_trails.move rename to audit-trail-move/sources/audit_trail.move index 89aebd37..dfc05dd4 100644 --- a/audit-trails-move/sources/audit_trails.move +++ b/audit-trail-move/sources/audit_trail.move @@ -1,18 +1,18 @@ // Copyright (c) 2025 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -/// Audit trails with role-based access control and timelock +/// Audit Trails with role-based access control and timelock /// /// An audit trail is a tamper-proof, sequential chain of notarized records where each entry /// references its predecessor, ensuring verifiable continuity and integrity. /// /// Records are addressed by trail_id + sequence_number -module audit_trails::audit_trails; +module audit_trail::main; -use audit_trails::capabilities::{Self, Capability}; -use audit_trails::locking::{Self, LockingConfig}; -use audit_trails::permissions::{Self, Permission}; -use audit_trails::record::{Self, Record}; +use audit_trail::capability::{Self, Capability}; +use audit_trail::locking::{Self, LockingConfig}; +use audit_trail::permission::{Self, Permission}; +use audit_trail::record::{Self, Record}; use iota::clock::{Self, Clock}; use iota::event; use iota::linked_table::{Self, LinkedTable}; @@ -46,14 +46,14 @@ public struct AuditTrail has key, store { records: LinkedTable>, /// Deletion locking rules locking_config: LockingConfig, - /// Role name -> set of permissions (TODO: implement) - permissions: VecMap>, + /// A list of role definitions consisting of a unique role specifier and a list of associated permissions + roles: VecMap>, /// Set at creation, cannot be changed immutable_metadata: TrailImmutableMetadata, /// Can be updated by holders of MetadataUpdate permission updatable_metadata: Option, /// Whitelist of all issued capability IDs (TODO: implement) - issued_capabilities: VecSet, + issued_capability: VecSet, } // ===== Events ===== @@ -131,7 +131,7 @@ public fun create( // TODO: Initialize setup role with admin permissions (bootstrap) // The creator should receive a setup capability with PermissionAdmin + CapAdmin - // to configure roles and issue capabilities to other users. + // to configure roles and issue capability to other users. let trail = AuditTrail { id: trail_uid, @@ -140,10 +140,10 @@ public fun create( record_count, records, locking_config, - permissions: vec_map::empty(), + roles: vec_map::empty(), immutable_metadata: trail_metadata, updatable_metadata, - issued_capabilities: iota::vec_set::empty(), + issued_capability: iota::vec_set::empty(), }; transfer::share_object(trail); diff --git a/audit-trails-move/sources/capabilities.move b/audit-trail-move/sources/capability.move similarity index 95% rename from audit-trails-move/sources/capabilities.move rename to audit-trail-move/sources/capability.move index fe49bc5a..ebaa4e8d 100644 --- a/audit-trails-move/sources/capabilities.move +++ b/audit-trail-move/sources/capability.move @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 /// Role-based access control capabilities for audit trails -module audit_trails::capabilities; +module audit_trail::capability; /// Capability granting role-based access to an audit trail public struct Capability has key, store { diff --git a/audit-trails-move/sources/locking.move b/audit-trail-move/sources/locking.move similarity index 99% rename from audit-trails-move/sources/locking.move rename to audit-trail-move/sources/locking.move index c20ef502..1946cf90 100644 --- a/audit-trails-move/sources/locking.move +++ b/audit-trail-move/sources/locking.move @@ -5,7 +5,7 @@ /// /// Controls when records can be deleted based on time window (records locked for N seconds) /// or count window (last N records always locked). -module audit_trails::locking; +module audit_trail::locking; /// Controls when records can be deleted (time OR count based) public struct LockingConfig has copy, drop, store { diff --git a/audit-trail-move/sources/permission.move b/audit-trail-move/sources/permission.move new file mode 100644 index 00000000..6fa28c35 --- /dev/null +++ b/audit-trail-move/sources/permission.move @@ -0,0 +1,195 @@ +// Copyright (c) 2025 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +/// Permission system for role-based access control +module audit_trail::permission; + +use iota::vec_set::{Self, VecSet}; + +/// Existing permissions for the Audit Trail object +public enum Permission has copy, drop, store { + // --- Whole AUdit TRail related - Proposed role: `Admin` --- + /// Destroy the whole Audit Trail object + AuditTrailDelete, + + // --- Record Management - Proposed role: `RecordAdmin` --- + /// Add records to the trail + RecordAdd, + /// Delete records from the trail + RecordDelete, + /// Correct existing records in the trail + RecordCorrect, // TODO: Clarify if needed for MVP + + + // --- Locking Config - Proposed role: `LockingAdmin` --- + /// Edit the delete_lock configuration for records + RecordDeleteLockConfig, + /// Edit the delete_lock configuration for the whole Audit Trail + TrailDeleteLockConfig, + + + // --- Role Management - Proposed role: `RoleAdmin` --- + /// Add new roles with associated permissions + RolesAdd, + /// Update permissions associated with existing roles + RolesUpdate, + /// Delete existing roles + RolesDelete, + + // --- Capability Management - Proposed role: `CapAdmin` --- + /// Issue new capabilities + CapabilitiesAdd, + /// Revoke existing capabilities + CapabilitiesRevoke, + + // --- Meta Data related - Proposed role: `MetaDataAdmin` --- + /// Update the updatable metadata field + MetaDataUpdate, + /// Delete the updatable metadata field + MetaDataDelete, +} + +/// Create an empty permission set +public fun empty(): VecSet { + vec_set::empty() +} + +/// Add a permission to a set +public fun add(set: &mut VecSet, perm: Permission) { + vec_set::insert(set, perm); +} + +/// Create a permission set from a vector +public fun from_vec(perms: vector): VecSet { + let mut set = vec_set::empty(); + let mut i = 0; + let len = perms.length(); + while (i < len) { + vec_set::insert(&mut set, perms[i]); + i = i + 1; + }; + set +} + +/// Check if a set contains a specific permission +public fun has_permission(set: &VecSet, perm: &Permission): bool { + vec_set::contains(set, perm) +} + +// --------------------------- Functions creating permission sets for often used roles --------------------------- + +/// Create permissions typical used for the `Admin` rolepermissions +public fun admin_permissions(): VecSet { + let mut perms = vec_set::empty(); + perms.insert(audit_trail_delete()); + perms +} + +/// Create permissions typical used for the `RecordAdmin` role +public fun record_admin_permissions(): VecSet { + let mut perms = vec_set::empty(); + perms.insert(record_add()); + perms.insert(record_delete()); + perms.insert(record_correct()); + perms +} + +/// Create permissions typical used for the `LockingAdmin` role +public fun locking_admin_permissions(): VecSet { + let mut perms = vec_set::empty(); + perms.insert(record_delete_lock_config()); + perms.insert(trail_delete_lock_config()); + perms +} + +/// Create permissions typical used for the `RoleAdmin` role +public fun role_admin_permissions(): VecSet { + let mut perms = vec_set::empty(); + perms.insert(roles_add()); + perms.insert(roles_update()); + perms.insert(roles_delete()); + perms +} + +/// Create permissions typical used for the `CapAdmin` role +public fun cap_admin_permissions(): VecSet { + let mut perms = vec_set::empty(); + perms.insert(capabilities_add()); + perms.insert(capabilities_revoke()); + perms +} + +/// Create permissions typical used for the `MetaDataAdmin` role +public fun metadata_admin_permissions(): VecSet { + let mut perms = vec_set::empty(); + perms.insert(meta_data_update()); + perms.insert(meta_data_delete()); + perms +} + +// --------------------------- Constructor functions for all Permission variants --------------------------- + +/// Returns a permission allowing to destroy the whole Audit Trail object +public fun audit_trail_delete(): Permission { + Permission::AuditTrailDelete +} + +/// Returns a permission allowing to add records to the trail +public fun record_add(): Permission { + Permission::RecordAdd +} + +/// Returns a permission allowing to delete records from the trail +public fun record_delete(): Permission { + Permission::RecordDelete +} + +/// Returns a permission allowing to correct existing records in the trail +public fun record_correct(): Permission { + Permission::RecordCorrect +} + +/// Returns a permission allowing to edit the delete_lock configuration for records +public fun record_delete_lock_config(): Permission { + Permission::RecordDeleteLockConfig +} + +/// Returns a permission allowing to edit the delete_lock configuration for the whole Audit Trail +public fun trail_delete_lock_config(): Permission { + Permission::TrailDeleteLockConfig +} + +/// Returns a permission allowing to add new roles with associated permissions +public fun roles_add(): Permission { + Permission::RolesAdd +} + +/// Returns a permission allowing to update permissions associated with existing roles +public fun roles_update(): Permission { + Permission::RolesUpdate +} + +/// Returns a permission allowing to delete existing roles +public fun roles_delete(): Permission { + Permission::RolesDelete +} + +/// Returns a permission allowing to issue new capabilities +public fun capabilities_add(): Permission { + Permission::CapabilitiesAdd +} + +/// Returns a permission allowing to revoke existing capabilities +public fun capabilities_revoke(): Permission { + Permission::CapabilitiesRevoke +} + +/// Returns a permission allowing to update the updatable_metadata field +public fun meta_data_update(): Permission { + Permission::MetaDataUpdate +} + +/// Returns a permission allowing to delete the updatable_metadata field +public fun meta_data_delete(): Permission { + Permission::MetaDataDelete +} diff --git a/audit-trails-move/sources/record.move b/audit-trail-move/sources/record.move similarity index 98% rename from audit-trails-move/sources/record.move rename to audit-trail-move/sources/record.move index 8f9adf93..06956ccc 100644 --- a/audit-trails-move/sources/record.move +++ b/audit-trail-move/sources/record.move @@ -5,7 +5,7 @@ /// /// A Record represents a single entry in an audit trail, stored in a LinkedTable /// and addressed by trail_id + sequence_number. -module audit_trails::record; +module audit_trail::record; use std::string::String; diff --git a/audit-trail-move/tests/permission_tests.move b/audit-trail-move/tests/permission_tests.move new file mode 100644 index 00000000..ac136747 --- /dev/null +++ b/audit-trail-move/tests/permission_tests.move @@ -0,0 +1,104 @@ +#[test_only] +module audit_trail::permission_tests; + +use audit_trail::permission::{Self}; +use iota::vec_set; + +#[test] +fun test_has_permission_empty_set() { + let set = permission::empty(); + assert!(vec_set::size(&set) == 0, 0); +} + +#[test] +fun test_has_permission_single_permission() { + let mut set = permission::empty(); + let perm = permission::record_add(); + permission::add(&mut set, perm); + + assert!(permission::has_permission(&set, &perm), 0); +} + +#[test] +fun test_has_permission_not_in_set() { + let mut set = permission::empty(); + permission::add(&mut set, permission::record_add()); + + let perm = permission::record_delete(); + assert!(!permission::has_permission(&set, &perm), 0); +} + +#[test] +fun test_has_permission_multiple_permission() { + let mut set = permission::empty(); + permission::add(&mut set, permission::record_add()); + permission::add(&mut set, permission::record_delete()); + permission::add(&mut set, permission::audit_trail_delete()); + + assert!(permission::has_permission(&set, &permission::record_add()), 0); + assert!(permission::has_permission(&set, &permission::record_delete()), 0); + assert!(permission::has_permission(&set, &permission::audit_trail_delete()), 0); + assert!(!permission::has_permission(&set, &permission::record_correct()), 0); +} + +#[test] +fun test_has_permission_from_vec() { + let perms = vector[ + permission::record_add(), + permission::record_delete(), + permission::meta_data_update(), + ]; + let set = permission::from_vec(perms); + + assert!(permission::has_permission(&set, &permission::record_add()), 0); + assert!(permission::has_permission(&set, &permission::record_delete()), 0); + assert!(permission::has_permission(&set, &permission::meta_data_update()), 0); + assert!(!permission::has_permission(&set, &permission::audit_trail_delete()), 0); +} + +#[test] +fun test_from_vec_empty() { + let perms = vector[]; + let set = permission::from_vec(perms); + + assert!(vec_set::size(&set) == 0, 0); +} + +#[test] +fun test_from_vec_single_permission() { + let perms = vector[permission::record_add()]; + let set = permission::from_vec(perms); + + assert!(vec_set::size(&set) == 1, 0); + assert!(permission::has_permission(&set, &permission::record_add()), 0); +} + +#[test] +fun test_from_vec_multiple_permission() { + let perms = vector[ + permission::record_add(), + permission::record_delete(), + permission::audit_trail_delete(), + ]; + let set = permission::from_vec(perms); + + assert!(vec_set::size(&set) == 3, 0); + assert!(permission::has_permission(&set, &permission::record_add()), 0); + assert!(permission::has_permission(&set, &permission::record_delete()), 0); + assert!(permission::has_permission(&set, &permission::audit_trail_delete()), 0); + assert!(!permission::has_permission(&set, &permission::record_correct()), 0); +} + +#[test] +#[expected_failure(abort_code = vec_set::EKeyAlreadyExists)] +fun test_from_vec_duplicate_permission() { + // VecSet should throw error EKeyAlreadyExists on duplicate insertions + let perms = vector[ + permission::record_add(), + permission::record_delete(), + permission::record_add(), // duplicate + ]; + let set = permission::from_vec(perms); + // The following line should not be reached due to the expected failure + assert!(vec_set::size(&set) == 2, 0); +} \ No newline at end of file diff --git a/audit-trails-rs/README.md b/audit-trail-rs/README.md similarity index 100% rename from audit-trails-rs/README.md rename to audit-trail-rs/README.md diff --git a/audit-trails-move/sources/permissions.move b/audit-trails-move/sources/permissions.move deleted file mode 100644 index f76d714d..00000000 --- a/audit-trails-move/sources/permissions.move +++ /dev/null @@ -1,63 +0,0 @@ -// Copyright (c) 2025 IOTA Stiftung -// SPDX-License-Identifier: Apache-2.0 - -/// Permission system for role-based access control -module audit_trails::permissions; - -use iota::vec_set::{Self, VecSet}; - -public struct Permission has copy, drop, store { - value: u8, -} - -/// Create an empty permission set -public fun empty(): VecSet { - vec_set::empty() -} - -/// Add a permission to a set -public fun add(set: &mut VecSet, perm: Permission) { - vec_set::insert(set, perm); -} - -/// Create a permission set from a vector -public fun from_vec(perms: vector): VecSet { - let mut set = vec_set::empty(); - let mut i = 0; - let len = perms.length(); - while (i < len) { - vec_set::insert(&mut set, perms[i]); - i = i + 1; - }; - set -} - -/// Check if a set contains a specific permission -public fun has_permission(set: &VecSet, perm: &Permission): bool { - vec_set::contains(set, perm) -} - -/// Permission to manage roles and permissions -public fun permission_admin(): Permission { - Permission { value: 0 } -} - -/// Permission to issue/revoke capabilities -public fun cap_admin(): Permission { - Permission { value: 0 } -} - -/// Permission to add records to the trail -public fun record_add(): Permission { - Permission { value: 0 } -} - -/// Permission to update trail metadata -public fun metadata_update(): Permission { - Permission { value: 0 } -} - -/// Permission to update locking configuration -public fun locking_update(): Permission { - Permission { value: 0 } -} From 632d1e785084f84518e5e794acbf83ef9269fb16 Mon Sep 17 00:00:00 2001 From: chrisgitiota Date: Mon, 22 Dec 2025 08:20:43 +0000 Subject: [PATCH 007/189] First version of initial roles config and Admin role Capability creation Unit tests are still buggy and will be fixed with the next commit. --- audit-trail-move/sources/audit_trail.move | 208 ++++++++++++++++++++-- audit-trail-move/sources/capability.move | 62 +++++-- audit-trail-move/sources/permission.move | 5 + 3 files changed, 245 insertions(+), 30 deletions(-) diff --git a/audit-trail-move/sources/audit_trail.move b/audit-trail-move/sources/audit_trail.move index dfc05dd4..5bdba96b 100644 --- a/audit-trail-move/sources/audit_trail.move +++ b/audit-trail-move/sources/audit_trail.move @@ -17,12 +17,21 @@ use iota::clock::{Self, Clock}; use iota::event; use iota::linked_table::{Self, LinkedTable}; use iota::vec_map::{Self, VecMap}; -use iota::vec_set::VecSet; +use iota::vec_set::{Self, VecSet}; use std::string::String; // ===== Errors ===== #[error] const ERecordNotFound: vector = b"Record not found at the given sequence number"; +#[error] +const ERoleDoesNotExist: vector = b"The specified role does not exist in the roles map"; +#[error] +const EPermissionDenied: vector = b"The role associated with the provided capability does not have the required permission"; +#[error] +const ETrailIdNotCorrect: vector = b"The trail ID associated with the provided capability does not match the audit trail"; + +// ===== Constants ===== +const INITIAL_ADMIN_ROLE_NAME: vector = b"Admin"; // ===== Core Structures ===== @@ -66,6 +75,8 @@ public struct AuditTrailCreated has copy, drop { has_initial_record: bool, } +// TODO: Add event for trail deletion + /// Emitted when a record is added to the trail /// Records are identified by trail_id + sequence_number public struct RecordAdded has copy, drop { @@ -75,6 +86,19 @@ public struct RecordAdded has copy, drop { timestamp: u64, } +// TODO: Add event for Record deletion and (if part of MVP) correction + +/// Emitted when a capability is issued +public struct CapabilityIssued has copy, drop { + trail_id: ID, + capability_id: ID, + role: String, + issued_to: address, + issued_by: address, + timestamp: u64, +} + + // ===== Constructors ===== /// Create immutable trail metadata @@ -88,6 +112,22 @@ public fun new_trail_metadata( // ===== Trail Creation ===== /// Create a new audit trail with optional initial record +/// +/// Initial roles config +/// -------------------- +/// Initializes the `roles` map with only one role, called "Admin" which is associated with the permissions +/// * TrailDelete +/// * CapabilitiesAdd +/// * CapabilitiesRevoke +/// * RolesAdd +/// * RolesUpdate +/// * RolesDelete +/// +/// Returns +/// ------- +/// * Capability with "Admin" role, allowing the creator to define custom +/// roles and issue capabilities to other users. +/// * Trail ID public fun create( initial_data: Option, initial_record_metadata: Option, @@ -96,7 +136,7 @@ public fun create( updatable_metadata: Option, clock: &Clock, ctx: &mut TxContext, -): ID { +): (Capability, ID) { let creator = ctx.sender(); let timestamp = clock::timestamp_ms(clock); @@ -129,9 +169,8 @@ public fun create( initial_data.destroy_none(); }; - // TODO: Initialize setup role with admin permissions (bootstrap) - // The creator should receive a setup capability with PermissionAdmin + CapAdmin - // to configure roles and issue capability to other users. + let mut roles = vec_map::empty>(); + roles.insert(initial_admin_role_name(), permission::admin_permissions()); let trail = AuditTrail { id: trail_uid, @@ -140,7 +179,7 @@ public fun create( record_count, records, locking_config, - roles: vec_map::empty(), + roles, immutable_metadata: trail_metadata, updatable_metadata, issued_capability: iota::vec_set::empty(), @@ -148,6 +187,12 @@ public fun create( transfer::share_object(trail); + let admin_cap = capability::new_capability( + initial_admin_role_name(), + trail_id, + ctx, + ); + event::emit(AuditTrailCreated { trail_id, creator, @@ -155,7 +200,11 @@ public fun create( has_initial_record, }); - trail_id + (admin_cap, trail_id) +} + +public fun initial_admin_role_name(): String { + INITIAL_ADMIN_ROLE_NAME.to_string() } // ===== Record Operations ===== @@ -252,17 +301,17 @@ public fun update_metadata( // ===== Trail Query Functions ===== /// Get the total number of records in the trail -public fun record_count(trail: &AuditTrail): u64 { +public fun trail_record_count(trail: &AuditTrail): u64 { trail.record_count } /// Get the trail creator address -public fun creator(trail: &AuditTrail): address { +public fun trail_creator(trail: &AuditTrail): address { trail.creator } /// Get the trail creation timestamp -public fun created_at(trail: &AuditTrail): u64 { +public fun trail_created_at(trail: &AuditTrail): u64 { trail.created_at } @@ -272,49 +321,170 @@ public fun trail_id(trail: &AuditTrail): ID { } /// Get the trail name (immutable metadata) -public fun name(trail: &AuditTrail): &Option { +public fun trail_name(trail: &AuditTrail): &Option { &trail.immutable_metadata.name } /// Get the trail description (immutable metadata) -public fun description(trail: &AuditTrail): &Option { +public fun trail_description(trail: &AuditTrail): &Option { &trail.immutable_metadata.description } /// Get the updatable metadata -public fun metadata(trail: &AuditTrail): &Option { +public fun trail_metadata(trail: &AuditTrail): &Option { &trail.updatable_metadata } /// Get the locking configuration -public fun locking_config(trail: &AuditTrail): &LockingConfig { +public fun trail_locking_config(trail: &AuditTrail): &LockingConfig { &trail.locking_config } /// Check if the trail is empty (no records) -public fun is_empty(trail: &AuditTrail): bool { +public fun trail_is_empty(trail: &AuditTrail): bool { linked_table::is_empty(&trail.records) } /// Get the first sequence number (None if empty) -public fun first_sequence(trail: &AuditTrail): Option { +public fun trail_first_sequence(trail: &AuditTrail): Option { *linked_table::front(&trail.records) } /// Get the last sequence number (None if empty) -public fun last_sequence(trail: &AuditTrail): Option { +public fun trail_last_sequence(trail: &AuditTrail): Option { *linked_table::back(&trail.records) } // ===== Record Query Functions ===== /// Get a record by sequence number -public fun get_record(trail: &AuditTrail, sequence_number: u64): &Record { +public fun trail_get_record(trail: &AuditTrail, sequence_number: u64): &Record { assert!(linked_table::contains(&trail.records, sequence_number), ERecordNotFound); linked_table::borrow(&trail.records, sequence_number) } /// Check if a record exists at the given sequence number -public fun has_record(trail: &AuditTrail, sequence_number: u64): bool { +public fun trail_has_record(trail: &AuditTrail, sequence_number: u64): bool { linked_table::contains(&trail.records, sequence_number) } + +// ===== Role and Capability related Functions ===== + +/// Get the permissions associated with a specific role. +/// Aborts with ERoleDoesNotExist if the role does not exist. +public fun trail_get_role_permissions( + trail: &AuditTrail, + role: &String, +): &VecSet { + assert!(vec_map::contains(&trail.roles, role), ERoleDoesNotExist); + vec_map::get(&trail.roles, role) +} + +/// Indicates if a provided capability has a specific permission. +public fun trail_has_capability_permission( + trail: &AuditTrail, + cap: &Capability, + permission: &Permission, +): bool { + assert!(trail.id() == cap.trail_id(), ETrailIdNotCorrect); + let permissions = trail.get_role_permissions(cap.role()); + vec_set::contains(permissions, permission) +} + +/// Create a new capability with a specific role +public fun trail_new_capability( + trail: &mut AuditTrail, + cap: &Capability, + role: &String, + ctx: &mut TxContext, +): Capability { + assert!(trail.has_capability_permission(cap, &permission::capabilities_add()), EPermissionDenied); + capability::new_capability( + *role, + trail.id(), + ctx, + ) +} + +/// Destroy an existing capability +/// Every owner of a capability is allowed to destroy it when no longer needed. +/// TODO: Clarify if we need to restrict access with the `CapabilitiesRevoke` permission here. +/// If yes, we also need a destroy function for Admin capabilities (without the need of another Admin capability). +/// Otherwise the last Admin capability holder will block the trail forever by not being able to destroy it. +public fun trail_destroy_capability( + trail: &mut AuditTrail, + cap_to_destroy: Capability, +) { + assert!(trail.id() == cap_to_destroy.trail_id(), ETrailIdNotCorrect); + // TODO: Implement revocation logic (e.g., remove from issued_capability set) + cap_to_destroy.destroy(); +} + +public fun trail_revoke_capability( + trail: &mut AuditTrail, + cap: &Capability, + cap_to_revoke: ID, +) { + assert!(trail.has_capability_permission(cap, &permission::capabilities_revoke()), EPermissionDenied); + // TODO: Implement revocation logic (e.g., remove from issued_capability set) +} + +/// Create a new role consisting of a role name and associated permissions +public fun trail_create_role( + trail: &mut AuditTrail, + cap: &Capability, + role: String, + permissions: VecSet, + _ctx: &mut TxContext, +) { + assert!(trail.has_capability_permission(cap, &permission::roles_add()), EPermissionDenied); + vec_map::insert(&mut trail.roles, role, permissions); +} + +/// Delete an existing role +public fun trail_delete_role( + trail: &mut AuditTrail, + cap: &Capability, + role: &String, + _ctx: &mut TxContext, +) { + assert!(trail.has_capability_permission(cap, &permission::roles_delete()), EPermissionDenied); + vec_map::remove(&mut trail.roles, role); +} + +/// Update permissions associated with an existing role +public fun trail_update_role_permissions( + trail: &mut AuditTrail, + cap: &Capability, + role: &String, + new_permissions: VecSet, + _ctx: &mut TxContext, +) { + assert!(trail.has_capability_permission(cap, &permission::roles_update()), EPermissionDenied); + assert!(vec_map::contains(&trail.roles, role), ERoleDoesNotExist); + vec_map::insert(&mut trail.roles, *role, new_permissions); +} + +// ===== public use statements ===== + +public use fun trail_id as AuditTrail.id; +public use fun trail_creator as AuditTrail.creator; +public use fun trail_created_at as AuditTrail.created_at; +public use fun trail_record_count as AuditTrail.record_count; +public use fun trail_name as AuditTrail.name; +public use fun trail_description as AuditTrail.description; +public use fun trail_metadata as AuditTrail.metadata; +public use fun trail_locking_config as AuditTrail.locking_config; +public use fun trail_is_empty as AuditTrail.is_empty; +public use fun trail_first_sequence as AuditTrail.first_sequence; +public use fun trail_last_sequence as AuditTrail.last_sequence; +public use fun trail_get_record as AuditTrail.get_record; +public use fun trail_has_record as AuditTrail.has_record; +public use fun trail_get_role_permissions as AuditTrail.get_role_permissions; +public use fun trail_has_capability_permission as AuditTrail.has_capability_permission; +public use fun trail_new_capability as AuditTrail.new_capability; +public use fun trail_destroy_capability as AuditTrail.destroy_capability; +public use fun trail_revoke_capability as AuditTrail.revoke_capability; +public use fun trail_create_role as AuditTrail.create_role; +public use fun trail_delete_role as AuditTrail.delete_role; +public use fun trail_update_role_permissions as AuditTrail.update_role_permissions; diff --git a/audit-trail-move/sources/capability.move b/audit-trail-move/sources/capability.move index ebaa4e8d..cbc965bd 100644 --- a/audit-trail-move/sources/capability.move +++ b/audit-trail-move/sources/capability.move @@ -4,32 +4,72 @@ /// Role-based access control capabilities for audit trails module audit_trail::capability; +use std::string::String; + +// ===== Core Structures ===== + /// Capability granting role-based access to an audit trail public struct Capability has key, store { id: UID, -} - -/// Create a setup capability for trail initialization -public fun new_setup_cap(ctx: &mut TxContext): Capability { - Capability { - id: object::new(ctx), - } + trail_id: ID, + role: String } /// Create a new capability with a specific role -public fun new_capability(ctx: &mut TxContext): Capability { - Capability { +public(package) fun new_capability( + role: String, + trail_id: ID, + ctx: &mut TxContext, +): Capability { + Capability { id: object::new(ctx), + role, + trail_id, } } + +// TODO: Is this needed? What is a setup capability? +// +// /// Create a setup capability for trail initialization +// public fun new_setup_cap(ctx: &mut TxContext): Capability { +// Capability { +// id: object::new(ctx), +// } +// } + + + /// Get the capability's ID public fun cap_id(cap: &Capability): ID { object::uid_to_inner(&cap.id) } +/// Get the capability's role +public fun cap_role(cap: &Capability): &String { + &cap.role +} + +/// Get the capability's trail ID +public fun cap_trail_id(cap: &Capability): ID { + cap.trail_id +} + +/// Check if the capability has a specific role +public fun cap_has_role(cap: &Capability, role: &String): bool { + &cap.role == role +} + /// Destroy a capability -public fun destroy_capability(cap: Capability) { - let Capability { id } = cap; +public(package) fun cap_destroy(cap: Capability) { + let Capability { id, role: _role, trail_id: _trail_id } = cap; object::delete(id); } + +// ===== public use statements ===== + +public use fun cap_id as Capability.id; +public use fun cap_role as Capability.role; +public use fun cap_trail_id as Capability.trail_id; +public use fun cap_has_role as Capability.has_role; +public use fun cap_destroy as Capability.destroy; \ No newline at end of file diff --git a/audit-trail-move/sources/permission.move b/audit-trail-move/sources/permission.move index 6fa28c35..71d34c26 100644 --- a/audit-trail-move/sources/permission.move +++ b/audit-trail-move/sources/permission.move @@ -82,6 +82,11 @@ public fun has_permission(set: &VecSet, perm: &Permission): bool { public fun admin_permissions(): VecSet { let mut perms = vec_set::empty(); perms.insert(audit_trail_delete()); + perms.insert(capabilities_add()); + perms.insert(capabilities_revoke()); + perms.insert(roles_add()); + perms.insert(roles_update()); + perms.insert(roles_delete()); perms } From f5b6b2a9e69196910a1355c1967bbfaa7a7fa257 Mon Sep 17 00:00:00 2001 From: chrisgitiota Date: Mon, 22 Dec 2025 10:55:20 +0000 Subject: [PATCH 008/189] Add missing tests for create AT --- audit-trail-move/tests/create_tests.move | 287 +++++++++++++++++++++++ 1 file changed, 287 insertions(+) create mode 100644 audit-trail-move/tests/create_tests.move diff --git a/audit-trail-move/tests/create_tests.move b/audit-trail-move/tests/create_tests.move new file mode 100644 index 00000000..046de20e --- /dev/null +++ b/audit-trail-move/tests/create_tests.move @@ -0,0 +1,287 @@ +#[test_only] +module audit_trail::create_tests; + +use audit_trail::main::{Self, AuditTrail, initial_admin_role_name}; +use audit_trail::locking::{Self}; +use audit_trail::capability::{Capability}; +use iota::test_scenario::{Self as ts}; +use iota::clock::{Self}; +use std::string::{Self}; + +/// Test data type for audit trail records +public struct TestData has store, copy, drop { + value: u64, + message: vector, +} + +fun destroy_capability(admin_cap: Capability, scenario: &ts::Scenario) { + let mut trail = ts::take_shared>(scenario); + trail.destroy_capability( admin_cap); + ts::return_shared(trail); +} + +#[test] +fun test_create_without_initial_record() { + let user = @0xA; + let mut scenario = ts::begin(user); + + { + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + clock.set_for_testing(1000); + + let locking_config = locking::new(std::option::none(), std::option::some(0)); + let trail_metadata = main::new_trail_metadata( + std::option::some(string::utf8(b"Test Trail")), + std::option::some(string::utf8(b"A test audit trail")), + ); + + let (admin_cap, trail_id) = main::create( + std::option::none(), + std::option::none(), + locking_config, + trail_metadata, + std::option::some(string::utf8(b"Updatable metadata")), + &clock, + ts::ctx(&mut scenario), + ); + + // Verify capability was created + assert!(admin_cap.role() == initial_admin_role_name(), 0); + assert!(admin_cap.trail_id() == trail_id, 1); + + // Clean up + clock::destroy_for_testing(clock); + destroy_capability(admin_cap, &scenario); + }; + + ts::next_tx(&mut scenario, user); + { + let trail = ts::take_shared>(&scenario); + + // Verify trail was created correctly + assert!(trail.trail_creator() == user, 2); + assert!(trail.trail_created_at() == 1000, 3); + assert!(trail.trail_record_count() == 0, 4); + + ts::return_shared(trail); + }; + + ts::end(scenario); +} + +#[test] +fun test_create_with_initial_record() { + let user = @0xB; + let mut scenario = ts::begin(user); + + { + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + clock.set_for_testing(2000); + + let locking_config = locking::new(std::option::some(86400), std::option::none()); // 1 day in seconds + let trail_metadata = main::new_trail_metadata( + std::option::some(string::utf8(b"Test Trail with Record")), + std::option::some(string::utf8(b"A test audit trail with initial record")), + ); + + let initial_data = TestData { + value: 42, + message: b"Hello, World!", + }; + + let (admin_cap, trail_id) = main::create( + std::option::some(initial_data), + std::option::some(string::utf8(b"Initial record metadata")), + locking_config, + trail_metadata, + std::option::some(string::utf8(b"Updatable metadata")), + &clock, + ts::ctx(&mut scenario), + ); + + // Verify capability + assert!(admin_cap.role() == initial_admin_role_name(), 0); + assert!(admin_cap.trail_id() == trail_id, 1); + + // Clean up + clock::destroy_for_testing(clock); + destroy_capability(admin_cap, &scenario); + }; + + ts::next_tx(&mut scenario, user); + { + let trail = ts::take_shared>(&scenario); + + // Verify trail with initial record + assert!(trail.trail_creator() == user, 2); + assert!(trail.trail_created_at() == 2000, 3); + assert!(trail.trail_record_count() == 1, 4); + + // Verify the initial record exists + assert!(trail.trail_has_record(0), 5); + + ts::return_shared(trail); + }; + + ts::end(scenario); +} + +#[test] +fun test_create_minimal_metadata() { + let user = @0xC; + let mut scenario = ts::begin(user); + + { + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + clock.set_for_testing(3000); + + let locking_config = locking::new(std::option::none(), std::option::some(0)); + let trail_metadata = main::new_trail_metadata( + std::option::none(), + std::option::none(), + ); + + let (admin_cap, _trail_id) = main::create( + std::option::none(), + std::option::none(), + locking_config, + trail_metadata, + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + // Verify capability was created + assert!(admin_cap.role() == initial_admin_role_name(), 0); + + // Clean up + destroy_capability(admin_cap, &scenario); + clock::destroy_for_testing(clock); + }; + + ts::next_tx(&mut scenario, user); + { + let trail = ts::take_shared>(&scenario); + + // Verify trail was created + assert!(trail.trail_creator() == user, 1); + assert!(trail.trail_created_at() == 3000, 2); + assert!(trail.trail_record_count() == 0, 3); + + ts::return_shared(trail); + }; + + ts::end(scenario); +} + +#[test] +fun test_create_with_locking_enabled() { + let user = @0xD; + let mut scenario = ts::begin(user); + + { + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + clock.set_for_testing(4000); + + let locking_config = locking::new(std::option::some(604800), std::option::none()); // 7 days in seconds + let trail_metadata = main::new_trail_metadata( + std::option::some(string::utf8(b"Locked Trail")), + std::option::none(), + ); + + let (admin_cap, _trail_id) = main::create( + std::option::none(), + std::option::none(), + locking_config, + trail_metadata, + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + // Clean up + destroy_capability(admin_cap, &scenario); + clock::destroy_for_testing(clock); + }; + + ts::next_tx(&mut scenario, user); + { + let trail = ts::take_shared>(&scenario); + + // Verify trail with locking enabled + assert!(trail.trail_creator() == user, 0); + assert!(trail.trail_record_count() == 0, 1); + + ts::return_shared(trail); + }; + + ts::end(scenario); +} + +#[test] +fun test_create_multiple_trails() { + let user = @0xE; + let mut scenario = ts::begin(user); + + let mut trail_ids = vector::empty(); + + // Create first trail + { + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + clock.set_for_testing(5000); + + let locking_config = locking::new(std::option::none(), std::option::some(0)); + let trail_metadata = main::new_trail_metadata( + std::option::some(string::utf8(b"Trail 1")), + std::option::none(), + ); + + let (admin_cap1, trail_id1) = main::create( + std::option::none(), + std::option::none(), + locking_config, + trail_metadata, + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + trail_ids.push_back(trail_id1); + ts::return_to_sender(&scenario, admin_cap1); + clock::destroy_for_testing(clock); + }; + + ts::next_tx(&mut scenario, user); + + // Create second trail + { + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + clock.set_for_testing(6000); + + let locking_config = locking::new(std::option::none(), std::option::some(0)); + let trail_metadata = main::new_trail_metadata( + std::option::some(string::utf8(b"Trail 2")), + std::option::none(), + ); + + let (admin_cap2, trail_id2) = main::create( + std::option::none(), + std::option::none(), + locking_config, + trail_metadata, + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + trail_ids.push_back(trail_id2); + + // Verify trails have different IDs + assert!(trail_ids[0] != trail_ids[1], 0); + + ts::return_to_sender(&scenario, admin_cap2); + clock::destroy_for_testing(clock); + }; + + ts::end(scenario); +} From 395a68f028a49e7bb27105379472642ac62e726c Mon Sep 17 00:00:00 2001 From: chrisgitiota Date: Mon, 22 Dec 2025 11:20:31 +0000 Subject: [PATCH 009/189] Fixes for the create AT tests --- audit-trail-move/sources/capability.move | 11 ++++++++--- audit-trail-move/tests/create_tests.move | 18 ++++++------------ 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/audit-trail-move/sources/capability.move b/audit-trail-move/sources/capability.move index cbc965bd..7cd6a819 100644 --- a/audit-trail-move/sources/capability.move +++ b/audit-trail-move/sources/capability.move @@ -38,8 +38,6 @@ public(package) fun new_capability( // } // } - - /// Get the capability's ID public fun cap_id(cap: &Capability): ID { object::uid_to_inner(&cap.id) @@ -66,10 +64,17 @@ public(package) fun cap_destroy(cap: Capability) { object::delete(id); } +#[test_only] +public fun cap_destroy_for_testing(cap: Capability) { + cap_destroy(cap); +} + // ===== public use statements ===== public use fun cap_id as Capability.id; public use fun cap_role as Capability.role; public use fun cap_trail_id as Capability.trail_id; public use fun cap_has_role as Capability.has_role; -public use fun cap_destroy as Capability.destroy; \ No newline at end of file +public use fun cap_destroy as Capability.destroy; +#[test_only] +public use fun cap_destroy_for_testing as Capability.destroy_for_testing; \ No newline at end of file diff --git a/audit-trail-move/tests/create_tests.move b/audit-trail-move/tests/create_tests.move index 046de20e..e29f8d49 100644 --- a/audit-trail-move/tests/create_tests.move +++ b/audit-trail-move/tests/create_tests.move @@ -14,12 +14,6 @@ public struct TestData has store, copy, drop { message: vector, } -fun destroy_capability(admin_cap: Capability, scenario: &ts::Scenario) { - let mut trail = ts::take_shared>(scenario); - trail.destroy_capability( admin_cap); - ts::return_shared(trail); -} - #[test] fun test_create_without_initial_record() { let user = @0xA; @@ -51,7 +45,7 @@ fun test_create_without_initial_record() { // Clean up clock::destroy_for_testing(clock); - destroy_capability(admin_cap, &scenario); + admin_cap.destroy_for_testing(); }; ts::next_tx(&mut scenario, user); @@ -105,7 +99,7 @@ fun test_create_with_initial_record() { // Clean up clock::destroy_for_testing(clock); - destroy_capability(admin_cap, &scenario); + admin_cap.destroy_for_testing(); }; ts::next_tx(&mut scenario, user); @@ -155,7 +149,7 @@ fun test_create_minimal_metadata() { assert!(admin_cap.role() == initial_admin_role_name(), 0); // Clean up - destroy_capability(admin_cap, &scenario); + admin_cap.destroy_for_testing(); clock::destroy_for_testing(clock); }; @@ -200,7 +194,7 @@ fun test_create_with_locking_enabled() { ); // Clean up - destroy_capability(admin_cap, &scenario); + admin_cap.destroy_for_testing(); clock::destroy_for_testing(clock); }; @@ -247,7 +241,7 @@ fun test_create_multiple_trails() { ); trail_ids.push_back(trail_id1); - ts::return_to_sender(&scenario, admin_cap1); + admin_cap1.destroy_for_testing(); clock::destroy_for_testing(clock); }; @@ -279,7 +273,7 @@ fun test_create_multiple_trails() { // Verify trails have different IDs assert!(trail_ids[0] != trail_ids[1], 0); - ts::return_to_sender(&scenario, admin_cap2); + admin_cap2.destroy_for_testing(); clock::destroy_for_testing(clock); }; From 713c494e3f28fc0ae874dbb43b4045964cc52132 Mon Sep 17 00:00:00 2001 From: chrisgitiota Date: Mon, 22 Dec 2025 11:38:01 +0000 Subject: [PATCH 010/189] New test test_create_metadata_admin_role() in create_tests.move --- audit-trail-move/tests/create_tests.move | 77 ++++++++++++++++++++ audit-trail-move/tests/permission_tests.move | 9 +++ 2 files changed, 86 insertions(+) diff --git a/audit-trail-move/tests/create_tests.move b/audit-trail-move/tests/create_tests.move index e29f8d49..cb554b35 100644 --- a/audit-trail-move/tests/create_tests.move +++ b/audit-trail-move/tests/create_tests.move @@ -279,3 +279,80 @@ fun test_create_multiple_trails() { ts::end(scenario); } + +/// Test creating a MetaDataAdmin role with metadata_admin_permissions. +/// +/// This test verifies that: +/// 1. A creator can create an AuditTrail and receive an admin capability +/// 2. The admin capability can be transferred to another user +/// 3. The user can use the capability to create a new MetaDataAdmin role +/// 4. The new role has the correct permissions (meta_data_update and meta_data_delete) +#[test] +fun test_create_metadata_admin_role() { + let creator = @0xA; + let user = @0xB; + let mut scenario = ts::begin(creator); + + // Creator creates the audit trail + { + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + clock.set_for_testing(1000); + + let locking_config = locking::new(std::option::none(), std::option::some(0)); + let trail_metadata = main::new_trail_metadata( + std::option::some(string::utf8(b"Test Trail for MetaDataAdmin")), + std::option::some(string::utf8(b"Testing metadata admin role creation")), + ); + + let (admin_cap, trail_id) = main::create( + std::option::none(), + std::option::none(), + locking_config, + trail_metadata, + std::option::some(string::utf8(b"Initial metadata")), + &clock, + ts::ctx(&mut scenario), + ); + + // Verify admin capability was created + assert!(admin_cap.role() == initial_admin_role_name(), 0); + assert!(admin_cap.trail_id() == trail_id, 1); + + // Transfer the admin capability to the user + transfer::public_transfer(admin_cap, user); + + clock::destroy_for_testing(clock); + }; + + // User receives the capability and creates the MetaDataAdmin role + ts::next_tx(&mut scenario, user); + { + let admin_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + + // Create the MetaDataAdmin role using the admin capability + let metadata_admin_role_name = string::utf8(b"MetaDataAdmin"); + let metadata_admin_perms = audit_trail::permission::metadata_admin_permissions(); + + trail.create_role( + &admin_cap, + metadata_admin_role_name, + metadata_admin_perms, + ts::ctx(&mut scenario), + ); + + // Verify the role was created by fetching its permissions + let role_perms = trail.get_role_permissions(&string::utf8(b"MetaDataAdmin")); + + // Verify the role has the correct permissions + assert!(audit_trail::permission::has_permission(role_perms, &audit_trail::permission::meta_data_update()), 2); + assert!(audit_trail::permission::has_permission(role_perms, &audit_trail::permission::meta_data_delete()), 3); + assert!(iota::vec_set::size(role_perms) == 2, 4); + + // Clean up + ts::return_to_sender(&scenario, admin_cap); + ts::return_shared(trail); + }; + + ts::end(scenario); +} diff --git a/audit-trail-move/tests/permission_tests.move b/audit-trail-move/tests/permission_tests.move index ac136747..2653c1e8 100644 --- a/audit-trail-move/tests/permission_tests.move +++ b/audit-trail-move/tests/permission_tests.move @@ -89,6 +89,15 @@ fun test_from_vec_multiple_permission() { assert!(!permission::has_permission(&set, &permission::record_correct()), 0); } +#[test] +fun test_metadata_admin_permissions() { + let perms = permission::metadata_admin_permissions(); + + assert!(permission::has_permission(&perms, &permission::meta_data_update()), 0); + assert!(permission::has_permission(&perms, &permission::meta_data_delete()), 0); + assert!(iota::vec_set::size(&perms) == 2, 0); +} + #[test] #[expected_failure(abort_code = vec_set::EKeyAlreadyExists)] fun test_from_vec_duplicate_permission() { From 6cb397b2ce059b046d49e9bf828ae59bfb8aeb3b Mon Sep 17 00:00:00 2001 From: chrisgitiota Date: Mon, 22 Dec 2025 11:39:40 +0000 Subject: [PATCH 011/189] Fix dprint issues --- audit-trail-rs/README.md | 2 +- bindings/wasm/audit_trails_wasm/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/audit-trail-rs/README.md b/audit-trail-rs/README.md index 02dd6176..71a444e3 100644 --- a/audit-trail-rs/README.md +++ b/audit-trail-rs/README.md @@ -1 +1 @@ -# IOTA Audit Trails \ No newline at end of file +# IOTA Audit Trails diff --git a/bindings/wasm/audit_trails_wasm/README.md b/bindings/wasm/audit_trails_wasm/README.md index 726f73a4..dcd4113e 100644 --- a/bindings/wasm/audit_trails_wasm/README.md +++ b/bindings/wasm/audit_trails_wasm/README.md @@ -1 +1 @@ -# IOTA Audit Trails WASM Library \ No newline at end of file +# IOTA Audit Trails WASM Library From bc512d842caf5bdc140b8a2fdc9a4d1b21e4761d Mon Sep 17 00:00:00 2001 From: Yasir Date: Mon, 22 Dec 2025 15:36:44 +0300 Subject: [PATCH 012/189] refactor: Rename audit_trails module to main and update locking configuration structure --- audit-trails-move/sources/audit_trails.move | 6 +- audit-trails-move/sources/locking.move | 121 ++++++++++++++------ 2 files changed, 92 insertions(+), 35 deletions(-) diff --git a/audit-trails-move/sources/audit_trails.move b/audit-trails-move/sources/audit_trails.move index 89aebd37..0fc85607 100644 --- a/audit-trails-move/sources/audit_trails.move +++ b/audit-trails-move/sources/audit_trails.move @@ -7,7 +7,7 @@ /// references its predecessor, ensuring verifiable continuity and integrity. /// /// Records are addressed by trail_id + sequence_number -module audit_trails::audit_trails; +module audit_trails::main; use audit_trails::capabilities::{Self, Capability}; use audit_trails::locking::{Self, LockingConfig}; @@ -47,7 +47,7 @@ public struct AuditTrail has key, store { /// Deletion locking rules locking_config: LockingConfig, /// Role name -> set of permissions (TODO: implement) - permissions: VecMap>, + roles: VecMap>, /// Set at creation, cannot be changed immutable_metadata: TrailImmutableMetadata, /// Can be updated by holders of MetadataUpdate permission @@ -140,7 +140,7 @@ public fun create( record_count, records, locking_config, - permissions: vec_map::empty(), + roles: vec_map::empty(), immutable_metadata: trail_metadata, updatable_metadata, issued_capabilities: iota::vec_set::empty(), diff --git a/audit-trails-move/sources/locking.move b/audit-trails-move/sources/locking.move index c20ef502..e59fe3d5 100644 --- a/audit-trails-move/sources/locking.move +++ b/audit-trails-move/sources/locking.move @@ -2,76 +2,114 @@ // SPDX-License-Identifier: Apache-2.0 /// Locking configuration for audit trail records -/// -/// Controls when records can be deleted based on time window (records locked for N seconds) -/// or count window (last N records always locked). module audit_trails::locking; -/// Controls when records can be deleted (time OR count based) -public struct LockingConfig has copy, drop, store { +/// Defines a locking window (time OR count based) +public struct LockingWindow has copy, drop, store { /// Records locked for N seconds after creation time_window_seconds: Option, /// Last N records are always locked count_window: Option, } -// ===== Constructors ===== +/// Top-level locking configuration for the audit trail +public struct LockingConfig has copy, drop, store { + /// Locking rules for record deletion + delete_record_lock: LockingWindow, +} + +// ===== LockingWindow Constructors ===== -/// Create a new locking configuration +/// Create a new locking window /// /// - `time_window_seconds`: Records are locked for N seconds after creation (None = no time lock) /// - `count_window`: Last N records are always locked (None = no count lock) -public fun new(time_window_seconds: Option, count_window: Option): LockingConfig { - LockingConfig { time_window_seconds, count_window } +public fun new_window(time_window_seconds: Option, count_window: Option): LockingWindow { + LockingWindow { time_window_seconds, count_window } +} + +/// Create a locking window with no restrictions +public fun window_none(): LockingWindow { + LockingWindow { + time_window_seconds: option::none(), + count_window: option::none(), + } +} + +/// Create a time-based locking window +public fun window_time_based(seconds: u64): LockingWindow { + LockingWindow { + time_window_seconds: option::some(seconds), + count_window: option::none(), + } +} + +/// Create a count-based locking window +public fun window_count_based(count: u64): LockingWindow { + LockingWindow { + time_window_seconds: option::none(), + count_window: option::some(count), + } +} + +// ===== LockingConfig Constructors ===== + +/// Create a new locking configuration +public fun new(delete_record_lock: LockingWindow): LockingConfig { + LockingConfig { delete_record_lock } } /// Create a locking config with no restrictions public fun none(): LockingConfig { LockingConfig { - time_window_seconds: option::none(), - count_window: option::none(), + delete_record_lock: window_none(), } } -/// Create a time-based locking config +/// Create a locking config with time-based record deletion lock public fun time_based(seconds: u64): LockingConfig { LockingConfig { - time_window_seconds: option::some(seconds), - count_window: option::none(), + delete_record_lock: window_time_based(seconds), } } -/// Create a count-based locking config +/// Create a locking config with count-based record deletion lock public fun count_based(count: u64): LockingConfig { LockingConfig { - time_window_seconds: option::none(), - count_window: option::some(count), + delete_record_lock: window_count_based(count), } } -// ===== Getters ===== +// ===== LockingWindow Getters ===== /// Get the time window in seconds (if set) -public fun time_window_seconds(config: &LockingConfig): &Option { - &config.time_window_seconds +public fun time_window_seconds(window: &LockingWindow): &Option { + &window.time_window_seconds } /// Get the count window (if set) -public fun count_window(config: &LockingConfig): &Option { - &config.count_window +public fun count_window(window: &LockingWindow): &Option { + &window.count_window } -// ===== Locking Logic ===== +// ===== LockingConfig Getters ===== + +/// Get the record deletion locking window +public fun delete_record_lock(config: &LockingConfig): &LockingWindow { + &config.delete_record_lock +} + +// ===== Locking Logic (LockingWindow) ===== /// Check if a record is locked based on time window /// /// Returns true if the record was created within the time window -public fun is_time_locked(config: &LockingConfig, record_timestamp: u64, current_time: u64): bool { - if (config.time_window_seconds.is_none()) { +public fun is_time_locked(window: &LockingWindow, record_timestamp: u64, current_time: u64): bool { + if (window.time_window_seconds.is_none()) { return false }; - let time_window_ms = (*config.time_window_seconds.borrow()) * 1000; + let time_window_ms = (*window.time_window_seconds.borrow()) * 1000; let record_age = current_time - record_timestamp; record_age < time_window_ms } @@ -79,18 +117,32 @@ public fun is_time_locked(config: &LockingConfig, record_timestamp: u64, current /// Check if a record is locked based on count window /// /// Returns true if the record is among the last N records -public fun is_count_locked(config: &LockingConfig, sequence_number: u64, total_records: u64): bool { - if (config.count_window.is_none()) { +public fun is_count_locked(window: &LockingWindow, sequence_number: u64, total_records: u64): bool { + if (window.count_window.is_none()) { return false }; - let count_window = *config.count_window.borrow(); + let count_window = *window.count_window.borrow(); let records_after = total_records - sequence_number - 1; records_after < count_window } -/// Check if a record is locked (either by time or count) +/// Check if a record is locked by a window (either by time or count) +public fun is_window_locked( + window: &LockingWindow, + sequence_number: u64, + record_timestamp: u64, + total_records: u64, + current_time: u64, +): bool { + is_time_locked(window, record_timestamp, current_time) + || is_count_locked(window, sequence_number, total_records) +} + +// ===== Locking Logic (LockingConfig) ===== + +/// Check if a record is locked for deletion public fun is_locked( config: &LockingConfig, sequence_number: u64, @@ -98,6 +150,11 @@ public fun is_locked( total_records: u64, current_time: u64, ): bool { - is_time_locked(config, record_timestamp, current_time) - || is_count_locked(config, sequence_number, total_records) + is_window_locked( + &config.delete_record_lock, + sequence_number, + record_timestamp, + total_records, + current_time, + ) } From ed79a98d49b96fd5832da6147c9bd0b1a2d33cc0 Mon Sep 17 00:00:00 2001 From: chrisgitiota Date: Mon, 22 Dec 2025 20:08:59 +0000 Subject: [PATCH 013/189] New test for the role-based access control delegation workflow --- audit-trail-move/sources/audit_trail.move | 107 ++++++----- audit-trail-move/sources/capability.move | 1 - audit-trail-move/tests/create_tests.move | 137 +++----------- audit-trail-move/tests/role_tests.move | 214 ++++++++++++++++++++++ audit-trail-move/tests/test_utils.move | 57 ++++++ 5 files changed, 363 insertions(+), 153 deletions(-) create mode 100644 audit-trail-move/tests/role_tests.move create mode 100644 audit-trail-move/tests/test_utils.move diff --git a/audit-trail-move/sources/audit_trail.move b/audit-trail-move/sources/audit_trail.move index 5bdba96b..8f4d48f7 100644 --- a/audit-trail-move/sources/audit_trail.move +++ b/audit-trail-move/sources/audit_trail.move @@ -214,7 +214,7 @@ public fun initial_admin_role_name(): String { /// Records are added sequentially with auto-assigned sequence numbers. /// /// TODO: Add capability parameter and permission check once implemented -public fun add_record( +public fun trail_add_record( trail: &mut AuditTrail, cap: &Capability, stored_data: D, @@ -222,7 +222,7 @@ public fun add_record( clock: &Clock, ctx: &mut TxContext, ) { - // TODO: check_permission(trail, cap, &permissions::record_add(), ctx); + assert!(trail.has_capability_permission(cap, &permission::record_add()), EPermissionDenied); let caller = ctx.sender(); let timestamp = clock::timestamp_ms(clock); @@ -368,7 +368,12 @@ public fun trail_has_record(trail: &AuditTrail, sequence_num linked_table::contains(&trail.records, sequence_number) } -// ===== Role and Capability related Functions ===== +/// Returns all records of the audit trail +public fun trail_records(trail: &AuditTrail): &LinkedTable> { + &trail.records +} + +// ===== Role related Functions ===== /// Get the permissions associated with a specific role. /// Aborts with ERoleDoesNotExist if the role does not exist. @@ -380,6 +385,58 @@ public fun trail_get_role_permissions( vec_map::get(&trail.roles, role) } +/// Create a new role consisting of a role name and associated permissions +public fun trail_create_role( + trail: &mut AuditTrail, + cap: &Capability, + role: String, + permissions: VecSet, + _ctx: &mut TxContext, +) { + assert!(trail.has_capability_permission(cap, &permission::roles_add()), EPermissionDenied); + vec_map::insert(&mut trail.roles, role, permissions); +} + +/// Delete an existing role +public fun trail_delete_role( + trail: &mut AuditTrail, + cap: &Capability, + role: &String, + _ctx: &mut TxContext, +) { + assert!(trail.has_capability_permission(cap, &permission::roles_delete()), EPermissionDenied); + vec_map::remove(&mut trail.roles, role); +} + +/// Update permissions associated with an existing role +public fun trail_update_role_permissions( + trail: &mut AuditTrail, + cap: &Capability, + role: &String, + new_permissions: VecSet, + _ctx: &mut TxContext, +) { + assert!(trail.has_capability_permission(cap, &permission::roles_update()), EPermissionDenied); + assert!(vec_map::contains(&trail.roles, role), ERoleDoesNotExist); + vec_map::insert(&mut trail.roles, *role, new_permissions); +} + +/// Returns the roles defined in the audit trail +public fun trail_roles(trail: &AuditTrail): &VecMap> { + &trail.roles +} + +/// Indicates if the specified role exists in the audit trail +public fun trail_has_role( + trail: &AuditTrail, + role: &String, +): bool { + vec_map::contains(&trail.roles, role) +} + + +// ===== Capability related Functions ===== + /// Indicates if a provided capability has a specific permission. public fun trail_has_capability_permission( trail: &AuditTrail, @@ -389,7 +446,7 @@ public fun trail_has_capability_permission( assert!(trail.id() == cap.trail_id(), ETrailIdNotCorrect); let permissions = trail.get_role_permissions(cap.role()); vec_set::contains(permissions, permission) -} +} /// Create a new capability with a specific role public fun trail_new_capability( @@ -429,48 +486,14 @@ public fun trail_revoke_capability( // TODO: Implement revocation logic (e.g., remove from issued_capability set) } -/// Create a new role consisting of a role name and associated permissions -public fun trail_create_role( - trail: &mut AuditTrail, - cap: &Capability, - role: String, - permissions: VecSet, - _ctx: &mut TxContext, -) { - assert!(trail.has_capability_permission(cap, &permission::roles_add()), EPermissionDenied); - vec_map::insert(&mut trail.roles, role, permissions); -} - -/// Delete an existing role -public fun trail_delete_role( - trail: &mut AuditTrail, - cap: &Capability, - role: &String, - _ctx: &mut TxContext, -) { - assert!(trail.has_capability_permission(cap, &permission::roles_delete()), EPermissionDenied); - vec_map::remove(&mut trail.roles, role); -} - -/// Update permissions associated with an existing role -public fun trail_update_role_permissions( - trail: &mut AuditTrail, - cap: &Capability, - role: &String, - new_permissions: VecSet, - _ctx: &mut TxContext, -) { - assert!(trail.has_capability_permission(cap, &permission::roles_update()), EPermissionDenied); - assert!(vec_map::contains(&trail.roles, role), ERoleDoesNotExist); - vec_map::insert(&mut trail.roles, *role, new_permissions); -} - // ===== public use statements ===== public use fun trail_id as AuditTrail.id; public use fun trail_creator as AuditTrail.creator; public use fun trail_created_at as AuditTrail.created_at; +public use fun trail_add_record as AuditTrail.add_record; public use fun trail_record_count as AuditTrail.record_count; +public use fun trail_records as AuditTrail.records; public use fun trail_name as AuditTrail.name; public use fun trail_description as AuditTrail.description; public use fun trail_metadata as AuditTrail.metadata; @@ -480,11 +503,13 @@ public use fun trail_first_sequence as AuditTrail.first_sequence; public use fun trail_last_sequence as AuditTrail.last_sequence; public use fun trail_get_record as AuditTrail.get_record; public use fun trail_has_record as AuditTrail.has_record; -public use fun trail_get_role_permissions as AuditTrail.get_role_permissions; public use fun trail_has_capability_permission as AuditTrail.has_capability_permission; public use fun trail_new_capability as AuditTrail.new_capability; public use fun trail_destroy_capability as AuditTrail.destroy_capability; public use fun trail_revoke_capability as AuditTrail.revoke_capability; +public use fun trail_get_role_permissions as AuditTrail.get_role_permissions; public use fun trail_create_role as AuditTrail.create_role; public use fun trail_delete_role as AuditTrail.delete_role; public use fun trail_update_role_permissions as AuditTrail.update_role_permissions; +public use fun trail_roles as AuditTrail.roles; +public use fun trail_has_role as AuditTrail.has_role; \ No newline at end of file diff --git a/audit-trail-move/sources/capability.move b/audit-trail-move/sources/capability.move index 7cd6a819..bf53c962 100644 --- a/audit-trail-move/sources/capability.move +++ b/audit-trail-move/sources/capability.move @@ -28,7 +28,6 @@ public(package) fun new_capability( } } - // TODO: Is this needed? What is a setup capability? // // /// Create a setup capability for trail initialization diff --git a/audit-trail-move/tests/create_tests.move b/audit-trail-move/tests/create_tests.move index cb554b35..1555a89b 100644 --- a/audit-trail-move/tests/create_tests.move +++ b/audit-trail-move/tests/create_tests.move @@ -4,39 +4,23 @@ module audit_trail::create_tests; use audit_trail::main::{Self, AuditTrail, initial_admin_role_name}; use audit_trail::locking::{Self}; use audit_trail::capability::{Capability}; +use audit_trail::test_utils::{setup_test_audit_trail, new_test_data, initial_time_for_testing, TestData}; use iota::test_scenario::{Self as ts}; use iota::clock::{Self}; use std::string::{Self}; -/// Test data type for audit trail records -public struct TestData has store, copy, drop { - value: u64, - message: vector, -} - #[test] fun test_create_without_initial_record() { let user = @0xA; let mut scenario = ts::begin(user); { - let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); - clock.set_for_testing(1000); - let locking_config = locking::new(std::option::none(), std::option::some(0)); - let trail_metadata = main::new_trail_metadata( - std::option::some(string::utf8(b"Test Trail")), - std::option::some(string::utf8(b"A test audit trail")), - ); - let (admin_cap, trail_id) = main::create( - std::option::none(), - std::option::none(), + let (admin_cap, trail_id) = setup_test_audit_trail( + &mut scenario, locking_config, - trail_metadata, - std::option::some(string::utf8(b"Updatable metadata")), - &clock, - ts::ctx(&mut scenario), + std::option::none() ); // Verify capability was created @@ -44,7 +28,6 @@ fun test_create_without_initial_record() { assert!(admin_cap.trail_id() == trail_id, 1); // Clean up - clock::destroy_for_testing(clock); admin_cap.destroy_for_testing(); }; @@ -54,7 +37,7 @@ fun test_create_without_initial_record() { // Verify trail was created correctly assert!(trail.trail_creator() == user, 2); - assert!(trail.trail_created_at() == 1000, 3); + assert!(trail.trail_created_at() == initial_time_for_testing(), 3); assert!(trail.trail_record_count() == 0, 4); ts::return_shared(trail); @@ -69,28 +52,13 @@ fun test_create_with_initial_record() { let mut scenario = ts::begin(user); { - let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); - clock.set_for_testing(2000); - let locking_config = locking::new(std::option::some(86400), std::option::none()); // 1 day in seconds - let trail_metadata = main::new_trail_metadata( - std::option::some(string::utf8(b"Test Trail with Record")), - std::option::some(string::utf8(b"A test audit trail with initial record")), - ); - - let initial_data = TestData { - value: 42, - message: b"Hello, World!", - }; + let initial_data = new_test_data(42, b"Hello, World!"); - let (admin_cap, trail_id) = main::create( - std::option::some(initial_data), - std::option::some(string::utf8(b"Initial record metadata")), + let (admin_cap, trail_id) = setup_test_audit_trail( + &mut scenario, locking_config, - trail_metadata, - std::option::some(string::utf8(b"Updatable metadata")), - &clock, - ts::ctx(&mut scenario), + std::option::some(initial_data) ); // Verify capability @@ -98,7 +66,6 @@ fun test_create_with_initial_record() { assert!(admin_cap.trail_id() == trail_id, 1); // Clean up - clock::destroy_for_testing(clock); admin_cap.destroy_for_testing(); }; @@ -108,7 +75,7 @@ fun test_create_with_initial_record() { // Verify trail with initial record assert!(trail.trail_creator() == user, 2); - assert!(trail.trail_created_at() == 2000, 3); + assert!(trail.trail_created_at() == initial_time_for_testing(), 3); assert!(trail.trail_record_count() == 1, 4); // Verify the initial record exists @@ -173,29 +140,16 @@ fun test_create_with_locking_enabled() { let user = @0xD; let mut scenario = ts::begin(user); - { - let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); - clock.set_for_testing(4000); - + { let locking_config = locking::new(std::option::some(604800), std::option::none()); // 7 days in seconds - let trail_metadata = main::new_trail_metadata( - std::option::some(string::utf8(b"Locked Trail")), - std::option::none(), - ); - - let (admin_cap, _trail_id) = main::create( - std::option::none(), - std::option::none(), + let (admin_cap, _trail_id) = setup_test_audit_trail( + &mut scenario, locking_config, - trail_metadata, - std::option::none(), - &clock, - ts::ctx(&mut scenario), + std::option::none() ); // Clean up admin_cap.destroy_for_testing(); - clock::destroy_for_testing(clock); }; ts::next_tx(&mut scenario, user); @@ -220,52 +174,27 @@ fun test_create_multiple_trails() { let mut trail_ids = vector::empty(); // Create first trail - { - let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); - clock.set_for_testing(5000); - + { let locking_config = locking::new(std::option::none(), std::option::some(0)); - let trail_metadata = main::new_trail_metadata( - std::option::some(string::utf8(b"Trail 1")), - std::option::none(), - ); - - let (admin_cap1, trail_id1) = main::create( - std::option::none(), - std::option::none(), + let (admin_cap1, trail_id1) = setup_test_audit_trail( + &mut scenario, locking_config, - trail_metadata, - std::option::none(), - &clock, - ts::ctx(&mut scenario), + std::option::none() ); trail_ids.push_back(trail_id1); admin_cap1.destroy_for_testing(); - clock::destroy_for_testing(clock); }; ts::next_tx(&mut scenario, user); // Create second trail { - let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); - clock.set_for_testing(6000); - let locking_config = locking::new(std::option::none(), std::option::some(0)); - let trail_metadata = main::new_trail_metadata( - std::option::some(string::utf8(b"Trail 2")), - std::option::none(), - ); - - let (admin_cap2, trail_id2) = main::create( - std::option::none(), - std::option::none(), + let (admin_cap2, trail_id2) = setup_test_audit_trail( + &mut scenario, locking_config, - trail_metadata, - std::option::none(), - &clock, - ts::ctx(&mut scenario), + std::option::none() ); trail_ids.push_back(trail_id2); @@ -274,7 +203,6 @@ fun test_create_multiple_trails() { assert!(trail_ids[0] != trail_ids[1], 0); admin_cap2.destroy_for_testing(); - clock::destroy_for_testing(clock); }; ts::end(scenario); @@ -294,24 +222,13 @@ fun test_create_metadata_admin_role() { let mut scenario = ts::begin(creator); // Creator creates the audit trail - { - let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); - clock.set_for_testing(1000); - + { let locking_config = locking::new(std::option::none(), std::option::some(0)); - let trail_metadata = main::new_trail_metadata( - std::option::some(string::utf8(b"Test Trail for MetaDataAdmin")), - std::option::some(string::utf8(b"Testing metadata admin role creation")), - ); - let (admin_cap, trail_id) = main::create( - std::option::none(), - std::option::none(), + let (admin_cap, trail_id) = setup_test_audit_trail( + &mut scenario, locking_config, - trail_metadata, - std::option::some(string::utf8(b"Initial metadata")), - &clock, - ts::ctx(&mut scenario), + std::option::none() ); // Verify admin capability was created @@ -319,9 +236,7 @@ fun test_create_metadata_admin_role() { assert!(admin_cap.trail_id() == trail_id, 1); // Transfer the admin capability to the user - transfer::public_transfer(admin_cap, user); - - clock::destroy_for_testing(clock); + transfer::public_transfer(admin_cap, user); }; // User receives the capability and creates the MetaDataAdmin role diff --git a/audit-trail-move/tests/role_tests.move b/audit-trail-move/tests/role_tests.move new file mode 100644 index 00000000..70d2174c --- /dev/null +++ b/audit-trail-move/tests/role_tests.move @@ -0,0 +1,214 @@ +#[test_only] +module audit_trail::role_tests; + +use audit_trail::permission::{Self}; +use audit_trail::locking::{Self}; +use audit_trail::main::{Self, AuditTrail, initial_admin_role_name}; +use audit_trail::test_utils::{Self, TestData, setup_test_audit_trail}; +use iota::test_scenario::{Self as ts}; +use iota::clock::{Self}; +use std::string::{Self}; +use audit_trail::capability::Capability; + +/// Test comprehensive role-based access control delegation workflow. +/// +/// This test validates the complete permission delegation chain: +/// 1. An admin user creates an audit trail with full admin permissions +/// 2. Admin creates two specialized roles: RoleAdmin (for role management) and CapAdmin (for capability management) +/// 3. Admin delegates these roles to different users by issuing capabilities +/// 4. RoleAdmin user leverages their permissions to create a RecordAdmin role +/// 5. CapAdmin user leverages their permissions to issue a RecordAdmin capability +/// 6. RecordAdmin user uses their capability to add a record to the audit trail +/// +/// This test ensures: +/// - Role creation works correctly with specific permission sets +/// - Capability issuance and transfer functions properly +/// - Permission delegation cascade works (Admin -> RoleAdmin -> RecordAdmin) +/// - Permission delegation cascade works (Admin -> CapAdmin -> RecordAdmin capability) +#[test] +fun test_role_based_permission_delegation() { + let admin_user = @0xAD; + let role_admin_user = @0xB0B; + let cap_admin_user = @0xCAB; + let record_admin_user = @0xDED; + + let mut scenario = ts::begin(admin_user); + + // Step 1: admin_user creates the audit trail + let trail_id = { + let locking_config = locking::new(std::option::none(), std::option::some(0)); + + let (admin_cap, trail_id) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::none() + ); + + // Verify admin capability was created with correct role and trail reference + assert!(admin_cap.role() == initial_admin_role_name(), 0); + assert!(admin_cap.trail_id() == trail_id, 1); + + // Transfer the admin capability to the user + transfer::public_transfer(admin_cap, admin_user); + + trail_id + }; + + // Step 2: Admin creates RoleAdmin and CapAdmin roles + ts::next_tx(&mut scenario, admin_user); + { + let admin_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + + // Verify initial state - should only have the initial admin role + assert!(trail.roles().size() == 1, 2); + + // Create RoleAdmin role + let role_admin_perms = permission::role_admin_permissions(); + trail.create_role( + &admin_cap, + string::utf8(b"RoleAdmin"), + role_admin_perms, + ts::ctx(&mut scenario), + ); + + // Create CapAdmin role + let cap_admin_perms = permission::cap_admin_permissions(); + trail.create_role( + &admin_cap, + string::utf8(b"CapAdmin"), + cap_admin_perms, + ts::ctx(&mut scenario), + ); + + // Verify both roles were created + assert!(trail.roles().size() == 3, 3); // Initial admin + RoleAdmin + CapAdmin + assert!(trail.has_role(&string::utf8(b"RoleAdmin")), 4); + assert!(trail.has_role(&string::utf8(b"CapAdmin")), 5); + + ts::return_to_sender(&scenario, admin_cap); + ts::return_shared(trail); + }; + + // Step 3: Admin creates capability for RoleAdmin and CapAdmin and transfers to the respective users + ts::next_tx(&mut scenario, admin_user); + { + let admin_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + + let role_admin_cap = trail.new_capability( + &admin_cap, + &string::utf8(b"RoleAdmin"), + ts::ctx(&mut scenario), + ); + + // Verify the capability was created with correct role and trail ID + assert!(role_admin_cap.role() == string::utf8(b"RoleAdmin"), 6); + assert!(role_admin_cap.trail_id() == trail_id, 7); + + iota::transfer::public_transfer(role_admin_cap, role_admin_user); + + let cap_admin_cap = trail.new_capability( + &admin_cap, + &string::utf8(b"CapAdmin"), + ts::ctx(&mut scenario), + ); + + // Verify the capability was created with correct role and trail ID + assert!(cap_admin_cap.role() == string::utf8(b"CapAdmin"), 8); + assert!(cap_admin_cap.trail_id() == trail_id, 9); + + iota::transfer::public_transfer(cap_admin_cap, cap_admin_user); + + ts::return_to_sender(&scenario, admin_cap); + ts::return_shared(trail); + }; + + + // Step 5: RoleAdmin creates RecordAdmin role (demonstrating delegated role management) + ts::next_tx(&mut scenario, role_admin_user); + { + let mut trail = ts::take_shared>(&scenario); + let role_admin_cap = ts::take_from_sender(&scenario); + + // Verify RoleAdmin has the correct role + assert!(role_admin_cap.role() == string::utf8(b"RoleAdmin"), 10); + + let record_admin_perms = permission::record_admin_permissions(); + trail.create_role( + &role_admin_cap, + string::utf8(b"RecordAdmin"), + record_admin_perms, + ts::ctx(&mut scenario), + ); + + // Verify RecordAdmin role was created successfully + assert!(trail.roles().size() == 4, 11); // Initial admin + RoleAdmin + CapAdmin + RecordAdmin + assert!(trail.has_role(&string::utf8(b"RecordAdmin")), 12); + + ts::return_to_sender(&scenario, role_admin_cap); + ts::return_shared(trail); + }; + + // Step 6: CapAdmin creates capability for RecordAdmin and transfers to record_admin_user + ts::next_tx(&mut scenario, cap_admin_user); + { + let mut trail = ts::take_shared>(&scenario); + let cap_admin_cap = ts::take_from_sender(&scenario); + + // Verify CapAdmin has the correct role + assert!(cap_admin_cap.role() == string::utf8(b"CapAdmin"), 13); + + let record_admin_cap = trail.new_capability( + &cap_admin_cap, + &string::utf8(b"RecordAdmin"), + ts::ctx(&mut scenario), + ); + + // Verify the capability was created with correct role and trail ID + assert!(record_admin_cap.role() == string::utf8(b"RecordAdmin"), 14); + assert!(record_admin_cap.trail_id() == trail_id, 15); + + iota::transfer::public_transfer(record_admin_cap, record_admin_user); + + ts::return_to_sender(&scenario, cap_admin_cap); + ts::return_shared(trail); + }; + + // Step 7: RecordAdmin adds a new record to the audit trail (demonstrating delegated record management) + ts::next_tx(&mut scenario, record_admin_user); + { + let mut trail = ts::take_shared>(&scenario); + let record_admin_cap = ts::take_from_sender(&scenario); + + // Verify RecordAdmin has the correct role + assert!(record_admin_cap.role() == string::utf8(b"RecordAdmin"), 16); + + // Verify initial record count + let initial_record_count = trail.records().length(); + + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + clock.set_for_testing(test_utils::initial_time_for_testing() + 1000); + + let test_data = test_utils::new_test_data(42, b"Test record added by RecordAdmin"); + + trail.add_record( + &record_admin_cap, + test_data, + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + // Verify the record was added successfully + assert!(trail.records().length() == initial_record_count + 1, 17); + + clock::destroy_for_testing(clock); + ts::return_to_sender(&scenario, record_admin_cap); + ts::return_shared(trail); + }; + + // Cleanup + ts::next_tx(&mut scenario, admin_user); + ts::end(scenario); +} \ No newline at end of file diff --git a/audit-trail-move/tests/test_utils.move b/audit-trail-move/tests/test_utils.move new file mode 100644 index 00000000..640b2198 --- /dev/null +++ b/audit-trail-move/tests/test_utils.move @@ -0,0 +1,57 @@ + +#[test_only] +module audit_trail::test_utils; + +use audit_trail::locking::{Self}; +use audit_trail::capability::{Capability}; +use iota::clock::{Self}; +use iota::test_scenario::{Self as ts, Scenario}; +use audit_trail::main::{Self}; +use std::string::{Self}; + +const INITIAL_TIME_FOR_TESTING: u64 = 1234; + +/// Test data type for audit trail records +public struct TestData has store, copy, drop { + value: u64, + message: vector, +} + +public(package) fun new_test_data(value: u64, message: vector): TestData { + TestData { + value, + message, + } +} + +public(package) fun initial_time_for_testing(): u64 { + INITIAL_TIME_FOR_TESTING +} + +/// Setup a test audit trail with optional initial data +public(package) fun setup_test_audit_trail(scenario: &mut Scenario, locking_config: locking::LockingConfig, initial_data: Option): (Capability, iota::object::ID) { + let (admin_cap, trail_id) = { + let mut clock = clock::create_for_testing(ts::ctx(scenario)); + clock.set_for_testing(INITIAL_TIME_FOR_TESTING); + + let trail_metadata = main::new_trail_metadata( + std::option::some(string::utf8(b"Setup Test Trail")), + std::option::none(), + ); + + let (admin_cap, trail_id) = main::create( + initial_data, + std::option::none(), + locking_config, + trail_metadata, + std::option::none(), + &clock, + ts::ctx(scenario), + ); + + clock::destroy_for_testing(clock); + (admin_cap, trail_id) + }; + + (admin_cap, trail_id) +} \ No newline at end of file From 09ae22b84f529f7a74fdb10f108c6c03df9b395e Mon Sep 17 00:00:00 2001 From: chrisgitiota Date: Mon, 22 Dec 2025 20:16:40 +0000 Subject: [PATCH 014/189] Add access control check for update_metadata() --- audit-trail-move/sources/audit_trail.move | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/audit-trail-move/sources/audit_trail.move b/audit-trail-move/sources/audit_trail.move index 8f4d48f7..871563d6 100644 --- a/audit-trail-move/sources/audit_trail.move +++ b/audit-trail-move/sources/audit_trail.move @@ -286,15 +286,13 @@ public fun update_locking_config( // ===== Metadata ===== /// Update the trail's mutable metadata -/// -/// TODO: Add capability parameter and permission check once implemented public fun update_metadata( trail: &mut AuditTrail, cap: &Capability, new_metadata: Option, _ctx: &mut TxContext, ) { - // TODO: check_permission(trail, cap, &permissions::metadata_update(), ctx); + assert!(trail.has_capability_permission(cap, &permission::meta_data_update()), EPermissionDenied); trail.updatable_metadata = new_metadata; } From 75c84879f28f709265f1562541a8c4d9a266c43a Mon Sep 17 00:00:00 2001 From: Christof Gerritsma Date: Tue, 23 Dec 2025 11:58:49 +0100 Subject: [PATCH 015/189] Update audit-trail-move/sources/permission.move Rename MetaDataUpdate to MetadataUpdate Co-authored-by: Yasir --- audit-trail-move/sources/permission.move | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/audit-trail-move/sources/permission.move b/audit-trail-move/sources/permission.move index 71d34c26..26fed32b 100644 --- a/audit-trail-move/sources/permission.move +++ b/audit-trail-move/sources/permission.move @@ -44,7 +44,7 @@ public enum Permission has copy, drop, store { // --- Meta Data related - Proposed role: `MetaDataAdmin` --- /// Update the updatable metadata field - MetaDataUpdate, + MetadataUpdate, /// Delete the updatable metadata field MetaDataDelete, } From 60f5562f5287581e9a35966ae27a25f3c737589c Mon Sep 17 00:00:00 2001 From: chrisgitiota Date: Tue, 23 Dec 2025 11:43:25 +0000 Subject: [PATCH 016/189] Implementation for the issued_capabilities whitelist management plus corresponding tests --- audit-trail-move/sources/audit_trail.move | 46 +- audit-trail-move/tests/capability_tests.move | 550 +++++++++++++++++++ audit-trail-move/tests/create_tests.move | 21 + 3 files changed, 602 insertions(+), 15 deletions(-) create mode 100644 audit-trail-move/tests/capability_tests.move diff --git a/audit-trail-move/sources/audit_trail.move b/audit-trail-move/sources/audit_trail.move index 871563d6..93da63b0 100644 --- a/audit-trail-move/sources/audit_trail.move +++ b/audit-trail-move/sources/audit_trail.move @@ -24,10 +24,12 @@ use std::string::String; #[error] const ERecordNotFound: vector = b"Record not found at the given sequence number"; #[error] -const ERoleDoesNotExist: vector = b"The specified role does not exist in the roles map"; +const ERoleDoesNotExist: vector = b"The specified role does not exist in the `roles` map"; #[error] const EPermissionDenied: vector = b"The role associated with the provided capability does not have the required permission"; #[error] +const ECapabilityHasBeenRevoked: vector = b"The provided capability has been revoked and is no longer valid"; +#[error] const ETrailIdNotCorrect: vector = b"The trail ID associated with the provided capability does not match the audit trail"; // ===== Constants ===== @@ -61,8 +63,8 @@ public struct AuditTrail has key, store { immutable_metadata: TrailImmutableMetadata, /// Can be updated by holders of MetadataUpdate permission updatable_metadata: Option, - /// Whitelist of all issued capability IDs (TODO: implement) - issued_capability: VecSet, + /// Whitelist of all issued capability IDs + issued_capabilities: VecSet, } // ===== Events ===== @@ -172,6 +174,15 @@ public fun create( let mut roles = vec_map::empty>(); roles.insert(initial_admin_role_name(), permission::admin_permissions()); + let admin_cap = capability::new_capability( + initial_admin_role_name(), + trail_id, + ctx, + ); + let mut issued_capabilities = vec_set::empty(); + issued_capabilities.insert(admin_cap.id()); + + let trail = AuditTrail { id: trail_uid, creator, @@ -182,16 +193,10 @@ public fun create( roles, immutable_metadata: trail_metadata, updatable_metadata, - issued_capability: iota::vec_set::empty(), + issued_capabilities, }; transfer::share_object(trail); - - let admin_cap = capability::new_capability( - initial_admin_role_name(), - trail_id, - ctx, - ); event::emit(AuditTrailCreated { trail_id, @@ -432,7 +437,6 @@ public fun trail_has_role( vec_map::contains(&trail.roles, role) } - // ===== Capability related Functions ===== /// Indicates if a provided capability has a specific permission. @@ -442,11 +446,13 @@ public fun trail_has_capability_permission( permission: &Permission, ): bool { assert!(trail.id() == cap.trail_id(), ETrailIdNotCorrect); + assert!(trail.issued_capabilities.contains(&cap.id()), ECapabilityHasBeenRevoked); let permissions = trail.get_role_permissions(cap.role()); vec_set::contains(permissions, permission) } /// Create a new capability with a specific role +/// Aborts with ERoleDoesNotExist if the role does not exist. public fun trail_new_capability( trail: &mut AuditTrail, cap: &Capability, @@ -454,11 +460,14 @@ public fun trail_new_capability( ctx: &mut TxContext, ): Capability { assert!(trail.has_capability_permission(cap, &permission::capabilities_add()), EPermissionDenied); - capability::new_capability( + assert!(trail.roles.contains(role), ERoleDoesNotExist); + let new_cap = capability::new_capability( *role, trail.id(), ctx, - ) + ); + trail.issued_capabilities.insert(new_cap.id()); + new_cap } /// Destroy an existing capability @@ -471,7 +480,7 @@ public fun trail_destroy_capability( cap_to_destroy: Capability, ) { assert!(trail.id() == cap_to_destroy.trail_id(), ETrailIdNotCorrect); - // TODO: Implement revocation logic (e.g., remove from issued_capability set) + trail.issued_capabilities.remove(&cap_to_destroy.id()); cap_to_destroy.destroy(); } @@ -481,7 +490,13 @@ public fun trail_revoke_capability( cap_to_revoke: ID, ) { assert!(trail.has_capability_permission(cap, &permission::capabilities_revoke()), EPermissionDenied); - // TODO: Implement revocation logic (e.g., remove from issued_capability set) + trail.issued_capabilities.remove(&cap_to_revoke); +} + +public fun trail_issued_capabilities( + trail: &AuditTrail, +): &VecSet { + &trail.issued_capabilities } // ===== public use statements ===== @@ -505,6 +520,7 @@ public use fun trail_has_capability_permission as AuditTrail.has_capability_perm public use fun trail_new_capability as AuditTrail.new_capability; public use fun trail_destroy_capability as AuditTrail.destroy_capability; public use fun trail_revoke_capability as AuditTrail.revoke_capability; +public use fun trail_issued_capabilities as AuditTrail.issued_capabilities; public use fun trail_get_role_permissions as AuditTrail.get_role_permissions; public use fun trail_create_role as AuditTrail.create_role; public use fun trail_delete_role as AuditTrail.delete_role; diff --git a/audit-trail-move/tests/capability_tests.move b/audit-trail-move/tests/capability_tests.move new file mode 100644 index 00000000..b5e97d3c --- /dev/null +++ b/audit-trail-move/tests/capability_tests.move @@ -0,0 +1,550 @@ +#[test_only] +module audit_trail::capability_tests; + +use audit_trail::permission::{Self}; +use audit_trail::locking::{Self}; +use audit_trail::main::{AuditTrail}; +use audit_trail::test_utils::{Self, TestData, setup_test_audit_trail}; +use iota::test_scenario::{Self as ts}; +use std::string::{Self}; +use audit_trail::capability::Capability; + +/// Test that new_capability() correctly creates a capability and tracks it in issued_capabilities. +/// +/// This test validates: +/// - Capability is created with correct role and trail ID +/// - Capability ID is added to the audit trail's issued_capabilities set +/// - Multiple capabilities can be issued and all are tracked +/// - Each capability has a unique ID +#[test] +fun test_new_capability() { + let admin_user = @0xAD; + let user1 = @0xB0B; + let user2 = @0xCAB; + + let mut scenario = ts::begin(admin_user); + + // Setup: Create audit trail with admin capability + let trail_id = { + let locking_config = locking::new(std::option::none(), std::option::some(0)); + + let (admin_cap, trail_id) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::none() + ); + + transfer::public_transfer(admin_cap, admin_user); + trail_id + }; + + // Create a custom role for testing + ts::next_tx(&mut scenario, admin_user); + { + let admin_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + + let record_admin_perms = permission::record_admin_permissions(); + trail.create_role( + &admin_cap, + string::utf8(b"RecordAdmin"), + record_admin_perms, + ts::ctx(&mut scenario), + ); + + ts::return_to_sender(&scenario, admin_cap); + ts::return_shared(trail); + }; + + // Test: Issue first capability + ts::next_tx(&mut scenario, admin_user); + let cap1_id = { + let admin_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + + // Verify initial state - only admin capability should be tracked + let initial_cap_count = trail.issued_capabilities().size(); + assert!(initial_cap_count == 1, 0); // Only admin cap + + let cap1 = trail.new_capability( + &admin_cap, + &string::utf8(b"RecordAdmin"), + ts::ctx(&mut scenario), + ); + + // Verify capability was created correctly + assert!(cap1.role() == string::utf8(b"RecordAdmin"), 1); + assert!(cap1.trail_id() == trail_id, 2); + + let cap1_id = object::id(&cap1); + + // Verify capability ID is tracked in issued_capabilities + assert!(trail.issued_capabilities().size() == initial_cap_count + 1, 3); + assert!(trail.issued_capabilities().contains(&cap1_id), 4); + + transfer::public_transfer(cap1, user1); + ts::return_to_sender(&scenario, admin_cap); + ts::return_shared(trail); + + cap1_id + }; + + // Test: Issue second capability + ts::next_tx(&mut scenario, admin_user); + let _cap2_id = { + let admin_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + + let previous_cap_count = trail.issued_capabilities().size(); + + let cap2 = trail.new_capability( + &admin_cap, + &string::utf8(b"RecordAdmin"), + ts::ctx(&mut scenario), + ); + + let cap2_id = object::id(&cap2); + + // Verify both capabilities are tracked + assert!(trail.issued_capabilities().size() == previous_cap_count + 1, 5); + assert!(trail.issued_capabilities().contains(&cap1_id), 6); + assert!(trail.issued_capabilities().contains(&cap2_id), 7); + + // Verify capabilities have unique IDs + assert!(cap1_id != cap2_id, 8); + + transfer::public_transfer(cap2, user2); + ts::return_to_sender(&scenario, admin_cap); + ts::return_shared(trail); + + cap2_id + }; + + ts::end(scenario); +} + +/// Test that revoke_capability() correctly revokes a capability and removes it from issued_capabilities. +/// +/// This test validates: +/// - Capability can be revoked by an authorized user +/// - Revoked capability ID is removed from issued_capabilities set +/// - Revoking one capability doesn't affect other capabilities +/// - Revoked capability object is properly destroyed +#[test] +fun test_revoke_capability() { + let admin_user = @0xAD; + let user1 = @0xB0B; + let user2 = @0xCAB; + + let mut scenario = ts::begin(admin_user); + + // Setup: Create audit trail with admin capability + let _trail_id = { + let locking_config = locking::new(std::option::none(), std::option::some(0)); + + let (admin_cap, trail_id) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::none() + ); + + transfer::public_transfer(admin_cap, admin_user); + trail_id + }; + + // Create a custom role for testing + ts::next_tx(&mut scenario, admin_user); + { + let admin_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + + let record_admin_perms = permission::record_admin_permissions(); + trail.create_role( + &admin_cap, + string::utf8(b"RecordAdmin"), + record_admin_perms, + ts::ctx(&mut scenario), + ); + + ts::return_to_sender(&scenario, admin_cap); + ts::return_shared(trail); + }; + + // Issue two capabilities + ts::next_tx(&mut scenario, admin_user); + let (cap1_id, cap2_id) = { + let admin_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + + let cap1 = trail.new_capability( + &admin_cap, + &string::utf8(b"RecordAdmin"), + ts::ctx(&mut scenario), + ); + let cap1_id = object::id(&cap1); + transfer::public_transfer(cap1, user1); + + let cap2 = trail.new_capability( + &admin_cap, + &string::utf8(b"RecordAdmin"), + ts::ctx(&mut scenario), + ); + let cap2_id = object::id(&cap2); + transfer::public_transfer(cap2, user2); + + ts::return_to_sender(&scenario, admin_cap); + ts::return_shared(trail); + + (cap1_id, cap2_id) + }; + + // Test: Revoke first capability + ts::next_tx(&mut scenario, user1); + { + let admin_cap = ts::take_from_address(&scenario, admin_user); + let mut trail = ts::take_shared>(&scenario); + let cap1 = ts::take_from_sender(&scenario); + + // Verify both capabilities are tracked before revocation + let cap_count_before = trail.issued_capabilities().size(); + assert!(trail.issued_capabilities().contains(&cap1_id), 0); + assert!(trail.issued_capabilities().contains(&cap2_id), 1); + + // Revoke the capability + trail.revoke_capability( + &admin_cap, + cap1.id() + ); + + // Verify capability was removed from tracking + assert!(trail.issued_capabilities().size() == cap_count_before - 1, 2); + assert!(!trail.issued_capabilities().contains(&cap1_id), 3); + + // Verify other capability is still tracked + assert!(trail.issued_capabilities().contains(&cap2_id), 4); + + ts::return_to_address(admin_user, admin_cap); + ts::return_to_sender(&scenario, cap1); + ts::return_shared(trail); + }; + + // Verify cap1 is still available to user1 -it has been revoked, not destroyed + ts::next_tx(&mut scenario, user1); + { + // This should not find cap1 since it was revoked + assert!(ts::has_most_recent_for_sender(&scenario), 5); + }; + + // Test: Revoke second capability + ts::next_tx(&mut scenario, user2); + { + let admin_cap = ts::take_from_address(&scenario, admin_user); + let mut trail = ts::take_shared>(&scenario); + let cap2 = ts::take_from_sender(&scenario); + + let cap_count_before = trail.issued_capabilities().size(); + + trail.revoke_capability( + &admin_cap, + cap2.id() + ); + + // Verify capability was removed from tracking + assert!(trail.issued_capabilities().size() == cap_count_before - 1, 6); + assert!(!trail.issued_capabilities().contains(&cap2_id), 7); + + ts::return_to_address(admin_user, admin_cap); + ts::return_to_sender(&scenario, cap2); + ts::return_shared(trail); + }; + + ts::end(scenario); +} + +/// Test that destroy_capability() correctly destroys a capability and removes it from issued_capabilities. +/// +/// This test validates: +/// - Capability owner can destroy their own capability +/// - Destroyed capability ID is removed from issued_capabilities set +/// - Destroying one capability doesn't affect other capabilities +/// - Capability object is properly destroyed and cannot be used again +#[test] +fun test_destroy_capability() { + let admin_user = @0xAD; + let user1 = @0xB0B; + let user2 = @0xCAB; + + let mut scenario = ts::begin(admin_user); + + // Setup: Create audit trail with admin capability + let trail_id = { + let locking_config = locking::new(std::option::none(), std::option::some(0)); + + let (admin_cap, trail_id) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::none() + ); + + transfer::public_transfer(admin_cap, admin_user); + trail_id + }; + + // Create a custom role for testing + ts::next_tx(&mut scenario, admin_user); + { + let admin_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + + let record_admin_perms = permission::record_admin_permissions(); + trail.create_role( + &admin_cap, + string::utf8(b"RecordAdmin"), + record_admin_perms, + ts::ctx(&mut scenario), + ); + + ts::return_to_sender(&scenario, admin_cap); + ts::return_shared(trail); + }; + + // Issue two capabilities + ts::next_tx(&mut scenario, admin_user); + let (cap1_id, cap2_id) = { + let admin_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + + let cap1 = trail.new_capability( + &admin_cap, + &string::utf8(b"RecordAdmin"), + ts::ctx(&mut scenario), + ); + let cap1_id = object::id(&cap1); + transfer::public_transfer(cap1, user1); + + let cap2 = trail.new_capability( + &admin_cap, + &string::utf8(b"RecordAdmin"), + ts::ctx(&mut scenario), + ); + let cap2_id = object::id(&cap2); + transfer::public_transfer(cap2, user2); + + ts::return_to_sender(&scenario, admin_cap); + ts::return_shared(trail); + + (cap1_id, cap2_id) + }; + + // Test: User1 destroys their own capability + ts::next_tx(&mut scenario, user1); + { + let mut trail = ts::take_shared>(&scenario); + let cap1 = ts::take_from_sender(&scenario); + + // Verify both capabilities are tracked before destruction + let cap_count_before = trail.issued_capabilities().size(); + assert!(trail.issued_capabilities().contains(&cap1_id), 0); + assert!(trail.issued_capabilities().contains(&cap2_id), 1); + + // Destroy the capability + trail.destroy_capability(cap1); + + // Verify capability was removed from tracking + assert!(trail.issued_capabilities().size() == cap_count_before - 1, 2); + assert!(!trail.issued_capabilities().contains(&cap1_id), 3); + + // Verify other capability is still tracked + assert!(trail.issued_capabilities().contains(&cap2_id), 4); + + ts::return_shared(trail); + }; + + // Verify cap1 is no longer available to user1 + ts::next_tx(&mut scenario, user1); + { + // This should not find cap1 since it was destroyed + assert!(!ts::has_most_recent_for_sender(&scenario), 5); + }; + + // Test: User2 destroys their own capability + ts::next_tx(&mut scenario, user2); + { + let mut trail = ts::take_shared>(&scenario); + let cap2 = ts::take_from_sender(&scenario); + + let cap_count_before = trail.issued_capabilities().size(); + + trail.destroy_capability(cap2); + + // Verify capability was removed from tracking + assert!(trail.issued_capabilities().size() == cap_count_before - 1, 6); + assert!(!trail.issued_capabilities().contains(&cap2_id), 7); + + ts::return_shared(trail); + }; + + // Verify only admin capability remains + ts::next_tx(&mut scenario, admin_user); + { + let trail = ts::take_shared>(&scenario); + + // Only the initial admin capability should remain + assert!(trail.issued_capabilities().size() == 1, 8); + + ts::return_shared(trail); + }; + + ts::end(scenario); +} + +/// Test capability lifecycle: creation, usage, and destruction in a complete workflow. +/// +/// This test validates: +/// - Multiple capabilities can be created for different roles +/// - Capabilities can be used to perform authorized actions +/// - Capabilities can be revoked or destroyed +/// - issued_capabilities tracking remains accurate throughout the lifecycle +#[test] +fun test_capability_lifecycle() { + let admin_user = @0xAD; + let record_admin_user = @0xB0B; + let role_admin_user = @0xCAB; + + let mut scenario = ts::begin(admin_user); + + // Setup: Create audit trail + let trail_id = { + let locking_config = locking::new(std::option::none(), std::option::some(0)); + + let (admin_cap, trail_id) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::none() + ); + + transfer::public_transfer(admin_cap, admin_user); + trail_id + }; + + // Create roles + ts::next_tx(&mut scenario, admin_user); + { + let admin_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + + // Initially only admin cap should be tracked + assert!(trail.issued_capabilities().size() == 1, 0); + + trail.create_role( + &admin_cap, + string::utf8(b"RecordAdmin"), + permission::record_admin_permissions(), + ts::ctx(&mut scenario), + ); + + trail.create_role( + &admin_cap, + string::utf8(b"RoleAdmin"), + permission::role_admin_permissions(), + ts::ctx(&mut scenario), + ); + + ts::return_to_sender(&scenario, admin_cap); + ts::return_shared(trail); + }; + + // Issue capabilities + ts::next_tx(&mut scenario, admin_user); + let (record_cap_id, role_cap_id) = { + let admin_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + + let record_cap = trail.new_capability( + &admin_cap, + &string::utf8(b"RecordAdmin"), + ts::ctx(&mut scenario), + ); + let record_cap_id = object::id(&record_cap); + transfer::public_transfer(record_cap, record_admin_user); + + let role_cap = trail.new_capability( + &admin_cap, + &string::utf8(b"RoleAdmin"), + ts::ctx(&mut scenario), + ); + let role_cap_id = object::id(&role_cap); + transfer::public_transfer(role_cap, role_admin_user); + + // Verify all capabilities are tracked + assert!(trail.issued_capabilities().size() == 3, 1); // admin + record + role + assert!(trail.issued_capabilities().contains(&record_cap_id), 2); + assert!(trail.issued_capabilities().contains(&role_cap_id), 3); + + ts::return_to_sender(&scenario, admin_cap); + ts::return_shared(trail); + + (record_cap_id, role_cap_id) + }; + + // Use RecordAdmin capability to add a record + ts::next_tx(&mut scenario, record_admin_user); + { + let mut trail = ts::take_shared>(&scenario); + let record_cap = ts::take_from_sender(&scenario); + + let mut clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); + clock.set_for_testing(test_utils::initial_time_for_testing() + 1000); + + let test_data = test_utils::new_test_data(1, b"Test record"); + trail.add_record( + &record_cap, + test_data, + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + iota::clock::destroy_for_testing(clock); + ts::return_to_sender(&scenario, record_cap); + ts::return_shared(trail); + }; + + // RecordAdmin destroys their capability + ts::next_tx(&mut scenario, record_admin_user); + { + let mut trail = ts::take_shared>(&scenario); + let record_cap = ts::take_from_sender(&scenario); + + trail.destroy_capability(record_cap); + + // Verify capability was removed + assert!(trail.issued_capabilities().size() == 2, 4); // admin + role + assert!(!trail.issued_capabilities().contains(&record_cap_id), 5); + + ts::return_shared(trail); + }; + + // Admin revokes RoleAdmin capability + ts::next_tx(&mut scenario, role_admin_user); + { + let admin_cap = ts::take_from_address(&scenario, admin_user); + let mut trail = ts::take_shared>(&scenario); + let role_cap = ts::take_from_sender(&scenario); + + trail.revoke_capability( + &admin_cap, + role_cap.id(), + ); + + // Verify capability was removed + assert!(trail.issued_capabilities().size() == 1, 6); // only admin remains + assert!(!trail.issued_capabilities().contains(&role_cap_id), 7); + + ts::return_to_address(admin_user, admin_cap); + ts::return_to_sender(&scenario, role_cap); + ts::return_shared(trail); + }; + + ts::end(scenario); +} \ No newline at end of file diff --git a/audit-trail-move/tests/create_tests.move b/audit-trail-move/tests/create_tests.move index 1555a89b..92db0172 100644 --- a/audit-trail-move/tests/create_tests.move +++ b/audit-trail-move/tests/create_tests.move @@ -1,4 +1,5 @@ #[test_only] +/// This module contains comprehensive tests for the AuditTrail creation functionality. module audit_trail::create_tests; use audit_trail::main::{Self, AuditTrail, initial_admin_role_name}; @@ -9,6 +10,10 @@ use iota::test_scenario::{Self as ts}; use iota::clock::{Self}; use std::string::{Self}; +/// Goals of this test: +/// - Verifies creating an AuditTrail with no initial record +/// - Checks admin capability creation with correct role and trail_id +/// - Validates trail metadata (creator, creation time, record count) #[test] fun test_create_without_initial_record() { let user = @0xA; @@ -46,6 +51,10 @@ fun test_create_without_initial_record() { ts::end(scenario); } +/// Goals of this test: +/// - Tests AuditTrail creation with an initial record +/// - Verifies the trail contains exactly one record after creation +/// - Validates the initial record exists at index 0 #[test] fun test_create_with_initial_record() { let user = @0xB; @@ -87,6 +96,10 @@ fun test_create_with_initial_record() { ts::end(scenario); } +/// Goals of this test: +/// - Tests creating a trail with minimal metadata (optional fields set to none) +/// - Uses a custom clock time to verify timestamp handling +/// - Ensures the system handles minimal configuration correctly #[test] fun test_create_minimal_metadata() { let user = @0xC; @@ -135,6 +148,10 @@ fun test_create_minimal_metadata() { ts::end(scenario); } +/// Goals of this test: +/// - Verifies AuditTrail creation with locking configuration enabled +/// - Tests a 7-day time-based lock period +/// - Validates the trail is created successfully with locking constraints #[test] fun test_create_with_locking_enabled() { let user = @0xD; @@ -166,6 +183,10 @@ fun test_create_with_locking_enabled() { ts::end(scenario); } +/// Goals of this test: +/// - Tests creating multiple independent AuditTrail instances +/// - Verifies each trail receives a unique ID +/// - Ensures multiple trails can coexist without conflicts #[test] fun test_create_multiple_trails() { let user = @0xE; From 520eda092903ae60503779e0dd85448d6d010030 Mon Sep 17 00:00:00 2001 From: chrisgitiota Date: Tue, 23 Dec 2025 11:47:50 +0000 Subject: [PATCH 017/189] Globally rename 'MetaData' to 'Metadata' --- audit-trail-move/sources/permission.move | 10 +++++----- audit-trail-move/tests/create_tests.move | 12 ++++++------ 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/audit-trail-move/sources/permission.move b/audit-trail-move/sources/permission.move index 26fed32b..01aadedb 100644 --- a/audit-trail-move/sources/permission.move +++ b/audit-trail-move/sources/permission.move @@ -42,11 +42,11 @@ public enum Permission has copy, drop, store { /// Revoke existing capabilities CapabilitiesRevoke, - // --- Meta Data related - Proposed role: `MetaDataAdmin` --- + // --- Meta Data related - Proposed role: `MetadataAdmin` --- /// Update the updatable metadata field MetadataUpdate, /// Delete the updatable metadata field - MetaDataDelete, + MetadataDelete, } /// Create an empty permission set @@ -124,7 +124,7 @@ public fun cap_admin_permissions(): VecSet { perms } -/// Create permissions typical used for the `MetaDataAdmin` role +/// Create permissions typical used for the `MetadataAdmin` role public fun metadata_admin_permissions(): VecSet { let mut perms = vec_set::empty(); perms.insert(meta_data_update()); @@ -191,10 +191,10 @@ public fun capabilities_revoke(): Permission { /// Returns a permission allowing to update the updatable_metadata field public fun meta_data_update(): Permission { - Permission::MetaDataUpdate + Permission::MetadataUpdate } /// Returns a permission allowing to delete the updatable_metadata field public fun meta_data_delete(): Permission { - Permission::MetaDataDelete + Permission::MetadataDelete } diff --git a/audit-trail-move/tests/create_tests.move b/audit-trail-move/tests/create_tests.move index 92db0172..a12d0b88 100644 --- a/audit-trail-move/tests/create_tests.move +++ b/audit-trail-move/tests/create_tests.move @@ -229,12 +229,12 @@ fun test_create_multiple_trails() { ts::end(scenario); } -/// Test creating a MetaDataAdmin role with metadata_admin_permissions. +/// Test creating a MetadataAdmin role with metadata_admin_permissions. /// /// This test verifies that: /// 1. A creator can create an AuditTrail and receive an admin capability /// 2. The admin capability can be transferred to another user -/// 3. The user can use the capability to create a new MetaDataAdmin role +/// 3. The user can use the capability to create a new MetadataAdmin role /// 4. The new role has the correct permissions (meta_data_update and meta_data_delete) #[test] fun test_create_metadata_admin_role() { @@ -260,14 +260,14 @@ fun test_create_metadata_admin_role() { transfer::public_transfer(admin_cap, user); }; - // User receives the capability and creates the MetaDataAdmin role + // User receives the capability and creates the MetadataAdmin role ts::next_tx(&mut scenario, user); { let admin_cap = ts::take_from_sender(&scenario); let mut trail = ts::take_shared>(&scenario); - // Create the MetaDataAdmin role using the admin capability - let metadata_admin_role_name = string::utf8(b"MetaDataAdmin"); + // Create the MetadataAdmin role using the admin capability + let metadata_admin_role_name = string::utf8(b"MetadataAdmin"); let metadata_admin_perms = audit_trail::permission::metadata_admin_permissions(); trail.create_role( @@ -278,7 +278,7 @@ fun test_create_metadata_admin_role() { ); // Verify the role was created by fetching its permissions - let role_perms = trail.get_role_permissions(&string::utf8(b"MetaDataAdmin")); + let role_perms = trail.get_role_permissions(&string::utf8(b"MetadataAdmin")); // Verify the role has the correct permissions assert!(audit_trail::permission::has_permission(role_perms, &audit_trail::permission::meta_data_update()), 2); From e5a71a1f518096161e72e2b27b32f404735a9a71 Mon Sep 17 00:00:00 2001 From: chrisgitiota Date: Tue, 23 Dec 2025 11:51:34 +0000 Subject: [PATCH 018/189] Rename 'create_tests' to 'create_audit_trail_tests' --- .../tests/{create_tests.move => create_audit_trail_tests.move} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename audit-trail-move/tests/{create_tests.move => create_audit_trail_tests.move} (99%) diff --git a/audit-trail-move/tests/create_tests.move b/audit-trail-move/tests/create_audit_trail_tests.move similarity index 99% rename from audit-trail-move/tests/create_tests.move rename to audit-trail-move/tests/create_audit_trail_tests.move index a12d0b88..b3bbcf44 100644 --- a/audit-trail-move/tests/create_tests.move +++ b/audit-trail-move/tests/create_audit_trail_tests.move @@ -1,6 +1,6 @@ #[test_only] /// This module contains comprehensive tests for the AuditTrail creation functionality. -module audit_trail::create_tests; +module audit_trail::create_audit_trail_tests; use audit_trail::main::{Self, AuditTrail, initial_admin_role_name}; use audit_trail::locking::{Self}; From cfb8c842efdfaba226e4c6dadcd6daf3b4c9d439 Mon Sep 17 00:00:00 2001 From: chrisgitiota Date: Tue, 23 Dec 2025 12:14:01 +0000 Subject: [PATCH 019/189] Rename all Permissions variants and creator functions according to the verb-subject format --- audit-trail-move/sources/audit_trail.move | 14 +-- audit-trail-move/sources/permission.move | 114 +++++++++--------- .../tests/create_audit_trail_tests.move | 4 +- audit-trail-move/tests/permission_tests.move | 62 +++++----- 4 files changed, 97 insertions(+), 97 deletions(-) diff --git a/audit-trail-move/sources/audit_trail.move b/audit-trail-move/sources/audit_trail.move index 93da63b0..9964982e 100644 --- a/audit-trail-move/sources/audit_trail.move +++ b/audit-trail-move/sources/audit_trail.move @@ -227,7 +227,7 @@ public fun trail_add_record( clock: &Clock, ctx: &mut TxContext, ) { - assert!(trail.has_capability_permission(cap, &permission::record_add()), EPermissionDenied); + assert!(trail.has_capability_permission(cap, &permission::add_record()), EPermissionDenied); let caller = ctx.sender(); let timestamp = clock::timestamp_ms(clock); @@ -297,7 +297,7 @@ public fun update_metadata( new_metadata: Option, _ctx: &mut TxContext, ) { - assert!(trail.has_capability_permission(cap, &permission::meta_data_update()), EPermissionDenied); + assert!(trail.has_capability_permission(cap, &permission::update_metadata()), EPermissionDenied); trail.updatable_metadata = new_metadata; } @@ -396,7 +396,7 @@ public fun trail_create_role( permissions: VecSet, _ctx: &mut TxContext, ) { - assert!(trail.has_capability_permission(cap, &permission::roles_add()), EPermissionDenied); + assert!(trail.has_capability_permission(cap, &permission::add_roles()), EPermissionDenied); vec_map::insert(&mut trail.roles, role, permissions); } @@ -407,7 +407,7 @@ public fun trail_delete_role( role: &String, _ctx: &mut TxContext, ) { - assert!(trail.has_capability_permission(cap, &permission::roles_delete()), EPermissionDenied); + assert!(trail.has_capability_permission(cap, &permission::delete_roles()), EPermissionDenied); vec_map::remove(&mut trail.roles, role); } @@ -419,7 +419,7 @@ public fun trail_update_role_permissions( new_permissions: VecSet, _ctx: &mut TxContext, ) { - assert!(trail.has_capability_permission(cap, &permission::roles_update()), EPermissionDenied); + assert!(trail.has_capability_permission(cap, &permission::update_roles()), EPermissionDenied); assert!(vec_map::contains(&trail.roles, role), ERoleDoesNotExist); vec_map::insert(&mut trail.roles, *role, new_permissions); } @@ -459,7 +459,7 @@ public fun trail_new_capability( role: &String, ctx: &mut TxContext, ): Capability { - assert!(trail.has_capability_permission(cap, &permission::capabilities_add()), EPermissionDenied); + assert!(trail.has_capability_permission(cap, &permission::add_capabilities()), EPermissionDenied); assert!(trail.roles.contains(role), ERoleDoesNotExist); let new_cap = capability::new_capability( *role, @@ -489,7 +489,7 @@ public fun trail_revoke_capability( cap: &Capability, cap_to_revoke: ID, ) { - assert!(trail.has_capability_permission(cap, &permission::capabilities_revoke()), EPermissionDenied); + assert!(trail.has_capability_permission(cap, &permission::revoke_capabilities()), EPermissionDenied); trail.issued_capabilities.remove(&cap_to_revoke); } diff --git a/audit-trail-move/sources/permission.move b/audit-trail-move/sources/permission.move index 01aadedb..2b5b5cec 100644 --- a/audit-trail-move/sources/permission.move +++ b/audit-trail-move/sources/permission.move @@ -10,43 +10,43 @@ use iota::vec_set::{Self, VecSet}; public enum Permission has copy, drop, store { // --- Whole AUdit TRail related - Proposed role: `Admin` --- /// Destroy the whole Audit Trail object - AuditTrailDelete, + DeleteAuditTrail, // --- Record Management - Proposed role: `RecordAdmin` --- /// Add records to the trail - RecordAdd, + AddRecord, /// Delete records from the trail - RecordDelete, + DeleteRecord, /// Correct existing records in the trail - RecordCorrect, // TODO: Clarify if needed for MVP + CorrectRecord, // TODO: Clarify if needed for MVP // --- Locking Config - Proposed role: `LockingAdmin` --- /// Edit the delete_lock configuration for records - RecordDeleteLockConfig, + ConfigRecordDeleteLock, /// Edit the delete_lock configuration for the whole Audit Trail - TrailDeleteLockConfig, + ConfigTrailDeleteLock, // --- Role Management - Proposed role: `RoleAdmin` --- /// Add new roles with associated permissions - RolesAdd, + AddRoles, /// Update permissions associated with existing roles - RolesUpdate, + UpdateRoles, /// Delete existing roles - RolesDelete, + DeleteRoles, // --- Capability Management - Proposed role: `CapAdmin` --- /// Issue new capabilities - CapabilitiesAdd, + AddCapabilities, /// Revoke existing capabilities - CapabilitiesRevoke, + RevokeCapabilities, // --- Meta Data related - Proposed role: `MetadataAdmin` --- /// Update the updatable metadata field - MetadataUpdate, + UpdateMetadata, /// Delete the updatable metadata field - MetadataDelete, + DeleteMetadata, } /// Create an empty permission set @@ -81,120 +81,120 @@ public fun has_permission(set: &VecSet, perm: &Permission): bool { /// Create permissions typical used for the `Admin` rolepermissions public fun admin_permissions(): VecSet { let mut perms = vec_set::empty(); - perms.insert(audit_trail_delete()); - perms.insert(capabilities_add()); - perms.insert(capabilities_revoke()); - perms.insert(roles_add()); - perms.insert(roles_update()); - perms.insert(roles_delete()); + perms.insert(delete_audit_trail()); + perms.insert(add_capabilities()); + perms.insert(revoke_capabilities()); + perms.insert(add_roles()); + perms.insert(update_roles()); + perms.insert(delete_roles()); perms } /// Create permissions typical used for the `RecordAdmin` role public fun record_admin_permissions(): VecSet { let mut perms = vec_set::empty(); - perms.insert(record_add()); - perms.insert(record_delete()); - perms.insert(record_correct()); + perms.insert(add_record()); + perms.insert(delete_record()); + perms.insert(correct_record()); perms } /// Create permissions typical used for the `LockingAdmin` role public fun locking_admin_permissions(): VecSet { let mut perms = vec_set::empty(); - perms.insert(record_delete_lock_config()); - perms.insert(trail_delete_lock_config()); + perms.insert(config_record_delete_lock()); + perms.insert(config_trail_delete_lock()); perms } /// Create permissions typical used for the `RoleAdmin` role public fun role_admin_permissions(): VecSet { let mut perms = vec_set::empty(); - perms.insert(roles_add()); - perms.insert(roles_update()); - perms.insert(roles_delete()); + perms.insert(add_roles()); + perms.insert(update_roles()); + perms.insert(delete_roles()); perms } /// Create permissions typical used for the `CapAdmin` role public fun cap_admin_permissions(): VecSet { let mut perms = vec_set::empty(); - perms.insert(capabilities_add()); - perms.insert(capabilities_revoke()); + perms.insert(add_capabilities()); + perms.insert(revoke_capabilities()); perms } /// Create permissions typical used for the `MetadataAdmin` role public fun metadata_admin_permissions(): VecSet { let mut perms = vec_set::empty(); - perms.insert(meta_data_update()); - perms.insert(meta_data_delete()); + perms.insert(update_metadata()); + perms.insert(delete_metadata()); perms } // --------------------------- Constructor functions for all Permission variants --------------------------- /// Returns a permission allowing to destroy the whole Audit Trail object -public fun audit_trail_delete(): Permission { - Permission::AuditTrailDelete +public fun delete_audit_trail(): Permission { + Permission::DeleteAuditTrail } /// Returns a permission allowing to add records to the trail -public fun record_add(): Permission { - Permission::RecordAdd +public fun add_record(): Permission { + Permission::AddRecord } /// Returns a permission allowing to delete records from the trail -public fun record_delete(): Permission { - Permission::RecordDelete +public fun delete_record(): Permission { + Permission::DeleteRecord } /// Returns a permission allowing to correct existing records in the trail -public fun record_correct(): Permission { - Permission::RecordCorrect +public fun correct_record(): Permission { + Permission::CorrectRecord } /// Returns a permission allowing to edit the delete_lock configuration for records -public fun record_delete_lock_config(): Permission { - Permission::RecordDeleteLockConfig +public fun config_record_delete_lock(): Permission { + Permission::ConfigRecordDeleteLock } /// Returns a permission allowing to edit the delete_lock configuration for the whole Audit Trail -public fun trail_delete_lock_config(): Permission { - Permission::TrailDeleteLockConfig +public fun config_trail_delete_lock(): Permission { + Permission::ConfigTrailDeleteLock } /// Returns a permission allowing to add new roles with associated permissions -public fun roles_add(): Permission { - Permission::RolesAdd +public fun add_roles(): Permission { + Permission::AddRoles } /// Returns a permission allowing to update permissions associated with existing roles -public fun roles_update(): Permission { - Permission::RolesUpdate +public fun update_roles(): Permission { + Permission::UpdateRoles } /// Returns a permission allowing to delete existing roles -public fun roles_delete(): Permission { - Permission::RolesDelete +public fun delete_roles(): Permission { + Permission::DeleteRoles } /// Returns a permission allowing to issue new capabilities -public fun capabilities_add(): Permission { - Permission::CapabilitiesAdd +public fun add_capabilities(): Permission { + Permission::AddCapabilities } /// Returns a permission allowing to revoke existing capabilities -public fun capabilities_revoke(): Permission { - Permission::CapabilitiesRevoke +public fun revoke_capabilities(): Permission { + Permission::RevokeCapabilities } /// Returns a permission allowing to update the updatable_metadata field -public fun meta_data_update(): Permission { - Permission::MetadataUpdate +public fun update_metadata(): Permission { + Permission::UpdateMetadata } /// Returns a permission allowing to delete the updatable_metadata field -public fun meta_data_delete(): Permission { - Permission::MetadataDelete +public fun delete_metadata(): Permission { + Permission::DeleteMetadata } diff --git a/audit-trail-move/tests/create_audit_trail_tests.move b/audit-trail-move/tests/create_audit_trail_tests.move index b3bbcf44..0eea7c74 100644 --- a/audit-trail-move/tests/create_audit_trail_tests.move +++ b/audit-trail-move/tests/create_audit_trail_tests.move @@ -281,8 +281,8 @@ fun test_create_metadata_admin_role() { let role_perms = trail.get_role_permissions(&string::utf8(b"MetadataAdmin")); // Verify the role has the correct permissions - assert!(audit_trail::permission::has_permission(role_perms, &audit_trail::permission::meta_data_update()), 2); - assert!(audit_trail::permission::has_permission(role_perms, &audit_trail::permission::meta_data_delete()), 3); + assert!(audit_trail::permission::has_permission(role_perms, &audit_trail::permission::update_metadata()), 2); + assert!(audit_trail::permission::has_permission(role_perms, &audit_trail::permission::delete_metadata()), 3); assert!(iota::vec_set::size(role_perms) == 2, 4); // Clean up diff --git a/audit-trail-move/tests/permission_tests.move b/audit-trail-move/tests/permission_tests.move index 2653c1e8..c7932851 100644 --- a/audit-trail-move/tests/permission_tests.move +++ b/audit-trail-move/tests/permission_tests.move @@ -13,7 +13,7 @@ fun test_has_permission_empty_set() { #[test] fun test_has_permission_single_permission() { let mut set = permission::empty(); - let perm = permission::record_add(); + let perm = permission::add_record(); permission::add(&mut set, perm); assert!(permission::has_permission(&set, &perm), 0); @@ -22,38 +22,38 @@ fun test_has_permission_single_permission() { #[test] fun test_has_permission_not_in_set() { let mut set = permission::empty(); - permission::add(&mut set, permission::record_add()); + permission::add(&mut set, permission::add_record()); - let perm = permission::record_delete(); + let perm = permission::delete_record(); assert!(!permission::has_permission(&set, &perm), 0); } #[test] fun test_has_permission_multiple_permission() { let mut set = permission::empty(); - permission::add(&mut set, permission::record_add()); - permission::add(&mut set, permission::record_delete()); - permission::add(&mut set, permission::audit_trail_delete()); + permission::add(&mut set, permission::add_record()); + permission::add(&mut set, permission::delete_record()); + permission::add(&mut set, permission::delete_audit_trail()); - assert!(permission::has_permission(&set, &permission::record_add()), 0); - assert!(permission::has_permission(&set, &permission::record_delete()), 0); - assert!(permission::has_permission(&set, &permission::audit_trail_delete()), 0); - assert!(!permission::has_permission(&set, &permission::record_correct()), 0); + assert!(permission::has_permission(&set, &permission::add_record()), 0); + assert!(permission::has_permission(&set, &permission::delete_record()), 0); + assert!(permission::has_permission(&set, &permission::delete_audit_trail()), 0); + assert!(!permission::has_permission(&set, &permission::correct_record()), 0); } #[test] fun test_has_permission_from_vec() { let perms = vector[ - permission::record_add(), - permission::record_delete(), - permission::meta_data_update(), + permission::add_record(), + permission::delete_record(), + permission::update_metadata(), ]; let set = permission::from_vec(perms); - assert!(permission::has_permission(&set, &permission::record_add()), 0); - assert!(permission::has_permission(&set, &permission::record_delete()), 0); - assert!(permission::has_permission(&set, &permission::meta_data_update()), 0); - assert!(!permission::has_permission(&set, &permission::audit_trail_delete()), 0); + assert!(permission::has_permission(&set, &permission::add_record()), 0); + assert!(permission::has_permission(&set, &permission::delete_record()), 0); + assert!(permission::has_permission(&set, &permission::update_metadata()), 0); + assert!(!permission::has_permission(&set, &permission::delete_audit_trail()), 0); } #[test] @@ -66,35 +66,35 @@ fun test_from_vec_empty() { #[test] fun test_from_vec_single_permission() { - let perms = vector[permission::record_add()]; + let perms = vector[permission::add_record()]; let set = permission::from_vec(perms); assert!(vec_set::size(&set) == 1, 0); - assert!(permission::has_permission(&set, &permission::record_add()), 0); + assert!(permission::has_permission(&set, &permission::add_record()), 0); } #[test] fun test_from_vec_multiple_permission() { let perms = vector[ - permission::record_add(), - permission::record_delete(), - permission::audit_trail_delete(), + permission::add_record(), + permission::delete_record(), + permission::delete_audit_trail(), ]; let set = permission::from_vec(perms); assert!(vec_set::size(&set) == 3, 0); - assert!(permission::has_permission(&set, &permission::record_add()), 0); - assert!(permission::has_permission(&set, &permission::record_delete()), 0); - assert!(permission::has_permission(&set, &permission::audit_trail_delete()), 0); - assert!(!permission::has_permission(&set, &permission::record_correct()), 0); + assert!(permission::has_permission(&set, &permission::add_record()), 0); + assert!(permission::has_permission(&set, &permission::delete_record()), 0); + assert!(permission::has_permission(&set, &permission::delete_audit_trail()), 0); + assert!(!permission::has_permission(&set, &permission::correct_record()), 0); } #[test] fun test_metadata_admin_permissions() { let perms = permission::metadata_admin_permissions(); - assert!(permission::has_permission(&perms, &permission::meta_data_update()), 0); - assert!(permission::has_permission(&perms, &permission::meta_data_delete()), 0); + assert!(permission::has_permission(&perms, &permission::update_metadata()), 0); + assert!(permission::has_permission(&perms, &permission::delete_metadata()), 0); assert!(iota::vec_set::size(&perms) == 2, 0); } @@ -103,9 +103,9 @@ fun test_metadata_admin_permissions() { fun test_from_vec_duplicate_permission() { // VecSet should throw error EKeyAlreadyExists on duplicate insertions let perms = vector[ - permission::record_add(), - permission::record_delete(), - permission::record_add(), // duplicate + permission::add_record(), + permission::delete_record(), + permission::add_record(), // duplicate ]; let set = permission::from_vec(perms); // The following line should not be reached due to the expected failure From 600ae26b2d49d87327bd4ba65224a8845ca22ca2 Mon Sep 17 00:00:00 2001 From: chrisgitiota Date: Tue, 23 Dec 2025 18:03:21 +0000 Subject: [PATCH 020/189] Rewrite of all LockingConfig creating function calls in Move tests --- audit-trail-move/tests/capability_tests.move | 8 ++++---- .../tests/create_audit_trail_tests.move | 14 +++++++------- audit-trail-move/tests/role_tests.move | 2 +- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/audit-trail-move/tests/capability_tests.move b/audit-trail-move/tests/capability_tests.move index b5e97d3c..fea0f99f 100644 --- a/audit-trail-move/tests/capability_tests.move +++ b/audit-trail-move/tests/capability_tests.move @@ -26,7 +26,7 @@ fun test_new_capability() { // Setup: Create audit trail with admin capability let trail_id = { - let locking_config = locking::new(std::option::none(), std::option::some(0)); + let locking_config = locking::new(locking::window_count_based(0)); let (admin_cap, trail_id) = setup_test_audit_trail( &mut scenario, @@ -140,7 +140,7 @@ fun test_revoke_capability() { // Setup: Create audit trail with admin capability let _trail_id = { - let locking_config = locking::new(std::option::none(), std::option::some(0)); + let locking_config = locking::new(locking::window_count_based(0)); let (admin_cap, trail_id) = setup_test_audit_trail( &mut scenario, @@ -278,7 +278,7 @@ fun test_destroy_capability() { // Setup: Create audit trail with admin capability let trail_id = { - let locking_config = locking::new(std::option::none(), std::option::some(0)); + let locking_config = locking::new(locking::window_count_based(0)); let (admin_cap, trail_id) = setup_test_audit_trail( &mut scenario, @@ -415,7 +415,7 @@ fun test_capability_lifecycle() { // Setup: Create audit trail let trail_id = { - let locking_config = locking::new(std::option::none(), std::option::some(0)); + let locking_config = locking::new(locking::window_count_based(0)); let (admin_cap, trail_id) = setup_test_audit_trail( &mut scenario, diff --git a/audit-trail-move/tests/create_audit_trail_tests.move b/audit-trail-move/tests/create_audit_trail_tests.move index 0eea7c74..6de3b79d 100644 --- a/audit-trail-move/tests/create_audit_trail_tests.move +++ b/audit-trail-move/tests/create_audit_trail_tests.move @@ -20,7 +20,7 @@ fun test_create_without_initial_record() { let mut scenario = ts::begin(user); { - let locking_config = locking::new(std::option::none(), std::option::some(0)); + let locking_config = locking::new(locking::window_count_based(0)); let (admin_cap, trail_id) = setup_test_audit_trail( &mut scenario, @@ -61,7 +61,7 @@ fun test_create_with_initial_record() { let mut scenario = ts::begin(user); { - let locking_config = locking::new(std::option::some(86400), std::option::none()); // 1 day in seconds + let locking_config = locking::new(locking::window_time_based(86400)); // 1 day in seconds let initial_data = new_test_data(42, b"Hello, World!"); let (admin_cap, trail_id) = setup_test_audit_trail( @@ -109,7 +109,7 @@ fun test_create_minimal_metadata() { let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); clock.set_for_testing(3000); - let locking_config = locking::new(std::option::none(), std::option::some(0)); + let locking_config = locking::new(locking::window_count_based(0)); let trail_metadata = main::new_trail_metadata( std::option::none(), std::option::none(), @@ -158,7 +158,7 @@ fun test_create_with_locking_enabled() { let mut scenario = ts::begin(user); { - let locking_config = locking::new(std::option::some(604800), std::option::none()); // 7 days in seconds + let locking_config = locking::new(locking::window_time_based(604800)); // 7 days in seconds let (admin_cap, _trail_id) = setup_test_audit_trail( &mut scenario, locking_config, @@ -196,7 +196,7 @@ fun test_create_multiple_trails() { // Create first trail { - let locking_config = locking::new(std::option::none(), std::option::some(0)); + let locking_config = locking::new(locking::window_count_based(0)); let (admin_cap1, trail_id1) = setup_test_audit_trail( &mut scenario, locking_config, @@ -211,7 +211,7 @@ fun test_create_multiple_trails() { // Create second trail { - let locking_config = locking::new(std::option::none(), std::option::some(0)); + let locking_config = locking::new(locking::window_count_based(0)); let (admin_cap2, trail_id2) = setup_test_audit_trail( &mut scenario, locking_config, @@ -244,7 +244,7 @@ fun test_create_metadata_admin_role() { // Creator creates the audit trail { - let locking_config = locking::new(std::option::none(), std::option::some(0)); + let locking_config = locking::new(locking::window_count_based(0)); let (admin_cap, trail_id) = setup_test_audit_trail( &mut scenario, diff --git a/audit-trail-move/tests/role_tests.move b/audit-trail-move/tests/role_tests.move index 70d2174c..e0fc15cc 100644 --- a/audit-trail-move/tests/role_tests.move +++ b/audit-trail-move/tests/role_tests.move @@ -36,7 +36,7 @@ fun test_role_based_permission_delegation() { // Step 1: admin_user creates the audit trail let trail_id = { - let locking_config = locking::new(std::option::none(), std::option::some(0)); + let locking_config = locking::new(locking::window_count_based(0)); let (admin_cap, trail_id) = setup_test_audit_trail( &mut scenario, From c029e29084e00bcffd4ab2fb510412bd29743063 Mon Sep 17 00:00:00 2001 From: chrisgitiota Date: Tue, 23 Dec 2025 18:39:24 +0000 Subject: [PATCH 021/189] Add access control check for AuditTrail.update_metadata() and `delete_record_lock` --- audit-trail-move/sources/audit_trail.move | 29 +++++++++++++------- audit-trail-move/sources/locking.move | 7 +++++ audit-trail-move/sources/permission.move | 33 ++++++++++++++--------- 3 files changed, 46 insertions(+), 23 deletions(-) diff --git a/audit-trail-move/sources/audit_trail.move b/audit-trail-move/sources/audit_trail.move index 9964982e..2ff77a6e 100644 --- a/audit-trail-move/sources/audit_trail.move +++ b/audit-trail-move/sources/audit_trail.move @@ -10,7 +10,7 @@ module audit_trail::main; use audit_trail::capability::{Self, Capability}; -use audit_trail::locking::{Self, LockingConfig}; +use audit_trail::locking::{Self, LockingConfig, LockingWindow, set_delete_record_lock}; use audit_trail::permission::{Self, Permission}; use audit_trail::record::{Self, Record}; use iota::clock::{Self, Clock}; @@ -217,8 +217,6 @@ public fun initial_admin_role_name(): String { /// Add a record to the trail /// /// Records are added sequentially with auto-assigned sequence numbers. -/// -/// TODO: Add capability parameter and permission check once implemented public fun trail_add_record( trail: &mut AuditTrail, cap: &Capability, @@ -256,7 +254,7 @@ public fun trail_add_record( // ===== Locking ===== /// Check if a record is locked (cannot be deleted) -public fun is_record_locked( +public fun trail_is_record_locked( trail: &AuditTrail, sequence_number: u64, clock: &Clock, @@ -276,22 +274,29 @@ public fun is_record_locked( } /// Update the locking configuration -/// -/// TODO: Add capability parameter and permission check once implemented -public fun update_locking_config( +public fun trail_update_locking_config( trail: &mut AuditTrail, cap: &Capability, new_config: LockingConfig, _ctx: &mut TxContext, ) { - // TODO: check_permission(trail, cap, &permissions::locking_update(), ctx); + assert!(trail.has_capability_permission(cap, &permission::update_locking_config()), EPermissionDenied); trail.locking_config = new_config; } -// ===== Metadata ===== +/// Update the `delete_record_lock` locking configuration +public fun trail_update_locking_config_for_delete_record( + trail: &mut AuditTrail, + cap: &Capability, + new_delete_record_lock: LockingWindow, + _ctx: &mut TxContext, +) { + assert!(trail.has_capability_permission(cap, &permission::update_locking_config_for_delete_record()), EPermissionDenied); + set_delete_record_lock(&mut trail.locking_config, new_delete_record_lock); +} /// Update the trail's mutable metadata -public fun update_metadata( +public fun trail_update_metadata( trail: &mut AuditTrail, cap: &Capability, new_metadata: Option, @@ -511,6 +516,10 @@ public use fun trail_name as AuditTrail.name; public use fun trail_description as AuditTrail.description; public use fun trail_metadata as AuditTrail.metadata; public use fun trail_locking_config as AuditTrail.locking_config; +public use fun trail_update_locking_config as AuditTrail.update_locking_config; +public use fun trail_is_record_locked as AuditTrail.is_record_locked; +public use fun trail_update_locking_config_for_delete_record as AuditTrail.update_locking_config_for_delete_record; +public use fun trail_update_metadata as AuditTrail.update_metadata; public use fun trail_is_empty as AuditTrail.is_empty; public use fun trail_first_sequence as AuditTrail.first_sequence; public use fun trail_last_sequence as AuditTrail.last_sequence; diff --git a/audit-trail-move/sources/locking.move b/audit-trail-move/sources/locking.move index be7a03bf..30c8d9cc 100644 --- a/audit-trail-move/sources/locking.move +++ b/audit-trail-move/sources/locking.move @@ -99,6 +99,13 @@ public fun delete_record_lock(config: &LockingConfig): &LockingWindow { &config.delete_record_lock } +// ===== LockingConfig Setters ===== + +/// Set the record deletion locking window +public(package) fun set_delete_record_lock(config: &mut LockingConfig, window: LockingWindow) { + config.delete_record_lock = window; +} + // ===== Locking Logic (LockingWindow) ===== /// Check if a record is locked based on time window diff --git a/audit-trail-move/sources/permission.move b/audit-trail-move/sources/permission.move index 2b5b5cec..d76bbd3c 100644 --- a/audit-trail-move/sources/permission.move +++ b/audit-trail-move/sources/permission.move @@ -22,11 +22,12 @@ public enum Permission has copy, drop, store { // --- Locking Config - Proposed role: `LockingAdmin` --- - /// Edit the delete_lock configuration for records - ConfigRecordDeleteLock, - /// Edit the delete_lock configuration for the whole Audit Trail - ConfigTrailDeleteLock, - + /// Update the whole locking configuration + UpdateLockingConfig, + /// Update the delete_record_lock configuration which is part of the locking configuration + UpdateLockingConfigForDeleteRecord, + /// Update the delete_lock configuration for the whole Audit Trail + UpdateLockingConfigForDeleteTrail, // --- Role Management - Proposed role: `RoleAdmin` --- /// Add new roles with associated permissions @@ -102,8 +103,9 @@ public fun record_admin_permissions(): VecSet { /// Create permissions typical used for the `LockingAdmin` role public fun locking_admin_permissions(): VecSet { let mut perms = vec_set::empty(); - perms.insert(config_record_delete_lock()); - perms.insert(config_trail_delete_lock()); + perms.insert(update_locking_config()); + perms.insert(update_locking_config_for_delete_trail()); + perms.insert(update_locking_config_for_delete_record()); perms } @@ -154,14 +156,19 @@ public fun correct_record(): Permission { Permission::CorrectRecord } -/// Returns a permission allowing to edit the delete_lock configuration for records -public fun config_record_delete_lock(): Permission { - Permission::ConfigRecordDeleteLock +/// Returns a permission allowing to update the whole locking configuration +public fun update_locking_config(): Permission { + Permission::UpdateLockingConfig +} + +/// Returns a permission allowing to update the delete_lock configuration for records +public fun update_locking_config_for_delete_record(): Permission { + Permission::UpdateLockingConfigForDeleteRecord } -/// Returns a permission allowing to edit the delete_lock configuration for the whole Audit Trail -public fun config_trail_delete_lock(): Permission { - Permission::ConfigTrailDeleteLock +/// Returns a permission allowing to update the delete_lock configuration for the whole Audit Trail +public fun update_locking_config_for_delete_trail(): Permission { + Permission::UpdateLockingConfigForDeleteTrail } /// Returns a permission allowing to add new roles with associated permissions From 213ac6b3a3f0dfdbb616de44325c0985d9220f53 Mon Sep 17 00:00:00 2001 From: Yasir Date: Mon, 5 Jan 2026 09:59:58 +0300 Subject: [PATCH 022/189] chore: fmt fixes & add prettier configuration --- audit-trail-move/.prettierignore | 1 + audit-trail-move/.prettierrc | 8 + audit-trail-move/sources/audit_trail.move | 92 ++++--- audit-trail-move/sources/capability.move | 12 +- audit-trail-move/sources/permission.move | 8 +- audit-trail-move/tests/capability_tests.move | 240 +++++++++--------- .../tests/create_audit_trail_tests.move | 159 ++++++------ audit-trail-move/tests/permission_tests.move | 20 +- audit-trail-move/tests/role_tests.move | 64 ++--- audit-trail-move/tests/test_utils.move | 28 +- 10 files changed, 335 insertions(+), 297 deletions(-) create mode 100644 audit-trail-move/.prettierignore create mode 100644 audit-trail-move/.prettierrc diff --git a/audit-trail-move/.prettierignore b/audit-trail-move/.prettierignore new file mode 100644 index 00000000..a007feab --- /dev/null +++ b/audit-trail-move/.prettierignore @@ -0,0 +1 @@ +build/* diff --git a/audit-trail-move/.prettierrc b/audit-trail-move/.prettierrc new file mode 100644 index 00000000..0ceb3060 --- /dev/null +++ b/audit-trail-move/.prettierrc @@ -0,0 +1,8 @@ +{ + "tabWidth": 4, + "printWidth": 100, + "useModuleLabel": true, + "autoGroupImports": "package", + "enableErrorDebug": false, + "wrapComments": false +} \ No newline at end of file diff --git a/audit-trail-move/sources/audit_trail.move b/audit-trail-move/sources/audit_trail.move index 2ff77a6e..0a29d60f 100644 --- a/audit-trail-move/sources/audit_trail.move +++ b/audit-trail-move/sources/audit_trail.move @@ -9,15 +9,19 @@ /// Records are addressed by trail_id + sequence_number module audit_trail::main; -use audit_trail::capability::{Self, Capability}; -use audit_trail::locking::{Self, LockingConfig, LockingWindow, set_delete_record_lock}; -use audit_trail::permission::{Self, Permission}; -use audit_trail::record::{Self, Record}; -use iota::clock::{Self, Clock}; -use iota::event; -use iota::linked_table::{Self, LinkedTable}; -use iota::vec_map::{Self, VecMap}; -use iota::vec_set::{Self, VecSet}; +use audit_trail::{ + capability::{Self, Capability}, + locking::{Self, LockingConfig, LockingWindow, set_delete_record_lock}, + permission::{Self, Permission}, + record::{Self, Record} +}; +use iota::{ + clock::{Self, Clock}, + event, + linked_table::{Self, LinkedTable}, + vec_map::{Self, VecMap}, + vec_set::{Self, VecSet} +}; use std::string::String; // ===== Errors ===== @@ -26,11 +30,14 @@ const ERecordNotFound: vector = b"Record not found at the given sequence num #[error] const ERoleDoesNotExist: vector = b"The specified role does not exist in the `roles` map"; #[error] -const EPermissionDenied: vector = b"The role associated with the provided capability does not have the required permission"; +const EPermissionDenied: vector = + b"The role associated with the provided capability does not have the required permission"; #[error] -const ECapabilityHasBeenRevoked: vector = b"The provided capability has been revoked and is no longer valid"; +const ECapabilityHasBeenRevoked: vector = + b"The provided capability has been revoked and is no longer valid"; #[error] -const ETrailIdNotCorrect: vector = b"The trail ID associated with the provided capability does not match the audit trail"; +const ETrailIdNotCorrect: vector = + b"The trail ID associated with the provided capability does not match the audit trail"; // ===== Constants ===== const INITIAL_ADMIN_ROLE_NAME: vector = b"Admin"; @@ -98,8 +105,7 @@ public struct CapabilityIssued has copy, drop { issued_to: address, issued_by: address, timestamp: u64, -} - +} // ===== Constructors ===== @@ -114,7 +120,7 @@ public fun new_trail_metadata( // ===== Trail Creation ===== /// Create a new audit trail with optional initial record -/// +/// /// Initial roles config /// -------------------- /// Initializes the `roles` map with only one role, called "Admin" which is associated with the permissions @@ -182,7 +188,6 @@ public fun create( let mut issued_capabilities = vec_set::empty(); issued_capabilities.insert(admin_cap.id()); - let trail = AuditTrail { id: trail_uid, creator, @@ -197,7 +202,7 @@ public fun create( }; transfer::share_object(trail); - + event::emit(AuditTrailCreated { trail_id, creator, @@ -280,7 +285,10 @@ public fun trail_update_locking_config( new_config: LockingConfig, _ctx: &mut TxContext, ) { - assert!(trail.has_capability_permission(cap, &permission::update_locking_config()), EPermissionDenied); + assert!( + trail.has_capability_permission(cap, &permission::update_locking_config()), + EPermissionDenied, + ); trail.locking_config = new_config; } @@ -291,7 +299,13 @@ public fun trail_update_locking_config_for_delete_record( new_delete_record_lock: LockingWindow, _ctx: &mut TxContext, ) { - assert!(trail.has_capability_permission(cap, &permission::update_locking_config_for_delete_record()), EPermissionDenied); + assert!( + trail.has_capability_permission( + cap, + &permission::update_locking_config_for_delete_record(), + ), + EPermissionDenied, + ); set_delete_record_lock(&mut trail.locking_config, new_delete_record_lock); } @@ -302,7 +316,10 @@ public fun trail_update_metadata( new_metadata: Option, _ctx: &mut TxContext, ) { - assert!(trail.has_capability_permission(cap, &permission::update_metadata()), EPermissionDenied); + assert!( + trail.has_capability_permission(cap, &permission::update_metadata()), + EPermissionDenied, + ); trail.updatable_metadata = new_metadata; } @@ -366,7 +383,10 @@ public fun trail_last_sequence(trail: &AuditTrail): Option(trail: &AuditTrail, sequence_number: u64): &Record { +public fun trail_get_record( + trail: &AuditTrail, + sequence_number: u64, +): &Record { assert!(linked_table::contains(&trail.records, sequence_number), ERecordNotFound); linked_table::borrow(&trail.records, sequence_number) } @@ -430,15 +450,14 @@ public fun trail_update_role_permissions( } /// Returns the roles defined in the audit trail -public fun trail_roles(trail: &AuditTrail): &VecMap> { +public fun trail_roles( + trail: &AuditTrail, +): &VecMap> { &trail.roles } /// Indicates if the specified role exists in the audit trail -public fun trail_has_role( - trail: &AuditTrail, - role: &String, -): bool { +public fun trail_has_role(trail: &AuditTrail, role: &String): bool { vec_map::contains(&trail.roles, role) } @@ -463,8 +482,11 @@ public fun trail_new_capability( cap: &Capability, role: &String, ctx: &mut TxContext, -): Capability { - assert!(trail.has_capability_permission(cap, &permission::add_capabilities()), EPermissionDenied); +): Capability { + assert!( + trail.has_capability_permission(cap, &permission::add_capabilities()), + EPermissionDenied, + ); assert!(trail.roles.contains(role), ERoleDoesNotExist); let new_cap = capability::new_capability( *role, @@ -494,13 +516,14 @@ public fun trail_revoke_capability( cap: &Capability, cap_to_revoke: ID, ) { - assert!(trail.has_capability_permission(cap, &permission::revoke_capabilities()), EPermissionDenied); + assert!( + trail.has_capability_permission(cap, &permission::revoke_capabilities()), + EPermissionDenied, + ); trail.issued_capabilities.remove(&cap_to_revoke); } -public fun trail_issued_capabilities( - trail: &AuditTrail, -): &VecSet { +public fun trail_issued_capabilities(trail: &AuditTrail): &VecSet { &trail.issued_capabilities } @@ -518,7 +541,8 @@ public use fun trail_metadata as AuditTrail.metadata; public use fun trail_locking_config as AuditTrail.locking_config; public use fun trail_update_locking_config as AuditTrail.update_locking_config; public use fun trail_is_record_locked as AuditTrail.is_record_locked; -public use fun trail_update_locking_config_for_delete_record as AuditTrail.update_locking_config_for_delete_record; +public use fun trail_update_locking_config_for_delete_record as + AuditTrail.update_locking_config_for_delete_record; public use fun trail_update_metadata as AuditTrail.update_metadata; public use fun trail_is_empty as AuditTrail.is_empty; public use fun trail_first_sequence as AuditTrail.first_sequence; @@ -535,4 +559,4 @@ public use fun trail_create_role as AuditTrail.create_role; public use fun trail_delete_role as AuditTrail.delete_role; public use fun trail_update_role_permissions as AuditTrail.update_role_permissions; public use fun trail_roles as AuditTrail.roles; -public use fun trail_has_role as AuditTrail.has_role; \ No newline at end of file +public use fun trail_has_role as AuditTrail.has_role; diff --git a/audit-trail-move/sources/capability.move b/audit-trail-move/sources/capability.move index bf53c962..87ca0ce9 100644 --- a/audit-trail-move/sources/capability.move +++ b/audit-trail-move/sources/capability.move @@ -12,16 +12,12 @@ use std::string::String; public struct Capability has key, store { id: UID, trail_id: ID, - role: String + role: String, } /// Create a new capability with a specific role -public(package) fun new_capability( - role: String, - trail_id: ID, - ctx: &mut TxContext, -): Capability { - Capability { +public(package) fun new_capability(role: String, trail_id: ID, ctx: &mut TxContext): Capability { + Capability { id: object::new(ctx), role, trail_id, @@ -76,4 +72,4 @@ public use fun cap_trail_id as Capability.trail_id; public use fun cap_has_role as Capability.has_role; public use fun cap_destroy as Capability.destroy; #[test_only] -public use fun cap_destroy_for_testing as Capability.destroy_for_testing; \ No newline at end of file +public use fun cap_destroy_for_testing as Capability.destroy_for_testing; diff --git a/audit-trail-move/sources/permission.move b/audit-trail-move/sources/permission.move index d76bbd3c..ddb4c3a8 100644 --- a/audit-trail-move/sources/permission.move +++ b/audit-trail-move/sources/permission.move @@ -11,7 +11,6 @@ public enum Permission has copy, drop, store { // --- Whole AUdit TRail related - Proposed role: `Admin` --- /// Destroy the whole Audit Trail object DeleteAuditTrail, - // --- Record Management - Proposed role: `RecordAdmin` --- /// Add records to the trail AddRecord, @@ -19,8 +18,6 @@ public enum Permission has copy, drop, store { DeleteRecord, /// Correct existing records in the trail CorrectRecord, // TODO: Clarify if needed for MVP - - // --- Locking Config - Proposed role: `LockingAdmin` --- /// Update the whole locking configuration UpdateLockingConfig, @@ -28,7 +25,6 @@ public enum Permission has copy, drop, store { UpdateLockingConfigForDeleteRecord, /// Update the delete_lock configuration for the whole Audit Trail UpdateLockingConfigForDeleteTrail, - // --- Role Management - Proposed role: `RoleAdmin` --- /// Add new roles with associated permissions AddRoles, @@ -36,13 +32,11 @@ public enum Permission has copy, drop, store { UpdateRoles, /// Delete existing roles DeleteRoles, - // --- Capability Management - Proposed role: `CapAdmin` --- /// Issue new capabilities AddCapabilities, /// Revoke existing capabilities RevokeCapabilities, - // --- Meta Data related - Proposed role: `MetadataAdmin` --- /// Update the updatable metadata field UpdateMetadata, @@ -83,7 +77,7 @@ public fun has_permission(set: &VecSet, perm: &Permission): bool { public fun admin_permissions(): VecSet { let mut perms = vec_set::empty(); perms.insert(delete_audit_trail()); - perms.insert(add_capabilities()); + perms.insert(add_capabilities()); perms.insert(revoke_capabilities()); perms.insert(add_roles()); perms.insert(update_roles()); diff --git a/audit-trail-move/tests/capability_tests.move b/audit-trail-move/tests/capability_tests.move index fea0f99f..8fc81add 100644 --- a/audit-trail-move/tests/capability_tests.move +++ b/audit-trail-move/tests/capability_tests.move @@ -1,13 +1,15 @@ #[test_only] module audit_trail::capability_tests; -use audit_trail::permission::{Self}; -use audit_trail::locking::{Self}; -use audit_trail::main::{AuditTrail}; -use audit_trail::test_utils::{Self, TestData, setup_test_audit_trail}; -use iota::test_scenario::{Self as ts}; -use std::string::{Self}; -use audit_trail::capability::Capability; +use audit_trail::{ + capability::Capability, + locking, + main::AuditTrail, + permission, + test_utils::{Self, TestData, setup_test_audit_trail} +}; +use iota::test_scenario as ts; +use std::string; /// Test that new_capability() correctly creates a capability and tracks it in issued_capabilities. /// @@ -21,29 +23,29 @@ fun test_new_capability() { let admin_user = @0xAD; let user1 = @0xB0B; let user2 = @0xCAB; - + let mut scenario = ts::begin(admin_user); - + // Setup: Create audit trail with admin capability let trail_id = { let locking_config = locking::new(locking::window_count_based(0)); - + let (admin_cap, trail_id) = setup_test_audit_trail( &mut scenario, locking_config, - std::option::none() + std::option::none(), ); - + transfer::public_transfer(admin_cap, admin_user); trail_id }; - + // Create a custom role for testing ts::next_tx(&mut scenario, admin_user); { let admin_cap = ts::take_from_sender(&scenario); let mut trail = ts::take_shared>(&scenario); - + let record_admin_perms = permission::record_admin_permissions(); trail.create_role( &admin_cap, @@ -51,75 +53,75 @@ fun test_new_capability() { record_admin_perms, ts::ctx(&mut scenario), ); - + ts::return_to_sender(&scenario, admin_cap); ts::return_shared(trail); }; - + // Test: Issue first capability ts::next_tx(&mut scenario, admin_user); let cap1_id = { let admin_cap = ts::take_from_sender(&scenario); let mut trail = ts::take_shared>(&scenario); - + // Verify initial state - only admin capability should be tracked let initial_cap_count = trail.issued_capabilities().size(); assert!(initial_cap_count == 1, 0); // Only admin cap - + let cap1 = trail.new_capability( &admin_cap, &string::utf8(b"RecordAdmin"), ts::ctx(&mut scenario), ); - + // Verify capability was created correctly assert!(cap1.role() == string::utf8(b"RecordAdmin"), 1); assert!(cap1.trail_id() == trail_id, 2); - + let cap1_id = object::id(&cap1); - + // Verify capability ID is tracked in issued_capabilities assert!(trail.issued_capabilities().size() == initial_cap_count + 1, 3); assert!(trail.issued_capabilities().contains(&cap1_id), 4); - + transfer::public_transfer(cap1, user1); ts::return_to_sender(&scenario, admin_cap); ts::return_shared(trail); - + cap1_id }; - + // Test: Issue second capability ts::next_tx(&mut scenario, admin_user); let _cap2_id = { let admin_cap = ts::take_from_sender(&scenario); let mut trail = ts::take_shared>(&scenario); - + let previous_cap_count = trail.issued_capabilities().size(); - + let cap2 = trail.new_capability( &admin_cap, &string::utf8(b"RecordAdmin"), ts::ctx(&mut scenario), ); - + let cap2_id = object::id(&cap2); - + // Verify both capabilities are tracked assert!(trail.issued_capabilities().size() == previous_cap_count + 1, 5); assert!(trail.issued_capabilities().contains(&cap1_id), 6); assert!(trail.issued_capabilities().contains(&cap2_id), 7); - + // Verify capabilities have unique IDs assert!(cap1_id != cap2_id, 8); - + transfer::public_transfer(cap2, user2); ts::return_to_sender(&scenario, admin_cap); ts::return_shared(trail); - + cap2_id }; - + ts::end(scenario); } @@ -135,29 +137,29 @@ fun test_revoke_capability() { let admin_user = @0xAD; let user1 = @0xB0B; let user2 = @0xCAB; - + let mut scenario = ts::begin(admin_user); - + // Setup: Create audit trail with admin capability let _trail_id = { let locking_config = locking::new(locking::window_count_based(0)); - + let (admin_cap, trail_id) = setup_test_audit_trail( &mut scenario, locking_config, - std::option::none() + std::option::none(), ); - + transfer::public_transfer(admin_cap, admin_user); trail_id }; - + // Create a custom role for testing ts::next_tx(&mut scenario, admin_user); { let admin_cap = ts::take_from_sender(&scenario); let mut trail = ts::take_shared>(&scenario); - + let record_admin_perms = permission::record_admin_permissions(); trail.create_role( &admin_cap, @@ -165,17 +167,17 @@ fun test_revoke_capability() { record_admin_perms, ts::ctx(&mut scenario), ); - + ts::return_to_sender(&scenario, admin_cap); ts::return_shared(trail); }; - + // Issue two capabilities ts::next_tx(&mut scenario, admin_user); let (cap1_id, cap2_id) = { let admin_cap = ts::take_from_sender(&scenario); let mut trail = ts::take_shared>(&scenario); - + let cap1 = trail.new_capability( &admin_cap, &string::utf8(b"RecordAdmin"), @@ -183,7 +185,7 @@ fun test_revoke_capability() { ); let cap1_id = object::id(&cap1); transfer::public_transfer(cap1, user1); - + let cap2 = trail.new_capability( &admin_cap, &string::utf8(b"RecordAdmin"), @@ -191,73 +193,73 @@ fun test_revoke_capability() { ); let cap2_id = object::id(&cap2); transfer::public_transfer(cap2, user2); - + ts::return_to_sender(&scenario, admin_cap); ts::return_shared(trail); - + (cap1_id, cap2_id) }; - + // Test: Revoke first capability ts::next_tx(&mut scenario, user1); { let admin_cap = ts::take_from_address(&scenario, admin_user); let mut trail = ts::take_shared>(&scenario); let cap1 = ts::take_from_sender(&scenario); - + // Verify both capabilities are tracked before revocation let cap_count_before = trail.issued_capabilities().size(); assert!(trail.issued_capabilities().contains(&cap1_id), 0); assert!(trail.issued_capabilities().contains(&cap2_id), 1); - + // Revoke the capability trail.revoke_capability( &admin_cap, - cap1.id() + cap1.id(), ); - + // Verify capability was removed from tracking assert!(trail.issued_capabilities().size() == cap_count_before - 1, 2); assert!(!trail.issued_capabilities().contains(&cap1_id), 3); - + // Verify other capability is still tracked assert!(trail.issued_capabilities().contains(&cap2_id), 4); - + ts::return_to_address(admin_user, admin_cap); ts::return_to_sender(&scenario, cap1); ts::return_shared(trail); }; - + // Verify cap1 is still available to user1 -it has been revoked, not destroyed ts::next_tx(&mut scenario, user1); { // This should not find cap1 since it was revoked assert!(ts::has_most_recent_for_sender(&scenario), 5); }; - + // Test: Revoke second capability ts::next_tx(&mut scenario, user2); { let admin_cap = ts::take_from_address(&scenario, admin_user); let mut trail = ts::take_shared>(&scenario); let cap2 = ts::take_from_sender(&scenario); - + let cap_count_before = trail.issued_capabilities().size(); - + trail.revoke_capability( &admin_cap, - cap2.id() + cap2.id(), ); - + // Verify capability was removed from tracking assert!(trail.issued_capabilities().size() == cap_count_before - 1, 6); assert!(!trail.issued_capabilities().contains(&cap2_id), 7); - + ts::return_to_address(admin_user, admin_cap); ts::return_to_sender(&scenario, cap2); ts::return_shared(trail); }; - + ts::end(scenario); } @@ -273,29 +275,29 @@ fun test_destroy_capability() { let admin_user = @0xAD; let user1 = @0xB0B; let user2 = @0xCAB; - + let mut scenario = ts::begin(admin_user); - + // Setup: Create audit trail with admin capability let trail_id = { let locking_config = locking::new(locking::window_count_based(0)); - + let (admin_cap, trail_id) = setup_test_audit_trail( &mut scenario, locking_config, - std::option::none() + std::option::none(), ); - + transfer::public_transfer(admin_cap, admin_user); trail_id }; - + // Create a custom role for testing ts::next_tx(&mut scenario, admin_user); { let admin_cap = ts::take_from_sender(&scenario); let mut trail = ts::take_shared>(&scenario); - + let record_admin_perms = permission::record_admin_permissions(); trail.create_role( &admin_cap, @@ -303,17 +305,17 @@ fun test_destroy_capability() { record_admin_perms, ts::ctx(&mut scenario), ); - + ts::return_to_sender(&scenario, admin_cap); ts::return_shared(trail); }; - + // Issue two capabilities ts::next_tx(&mut scenario, admin_user); let (cap1_id, cap2_id) = { let admin_cap = ts::take_from_sender(&scenario); let mut trail = ts::take_shared>(&scenario); - + let cap1 = trail.new_capability( &admin_cap, &string::utf8(b"RecordAdmin"), @@ -321,7 +323,7 @@ fun test_destroy_capability() { ); let cap1_id = object::id(&cap1); transfer::public_transfer(cap1, user1); - + let cap2 = trail.new_capability( &admin_cap, &string::utf8(b"RecordAdmin"), @@ -329,72 +331,72 @@ fun test_destroy_capability() { ); let cap2_id = object::id(&cap2); transfer::public_transfer(cap2, user2); - + ts::return_to_sender(&scenario, admin_cap); ts::return_shared(trail); - + (cap1_id, cap2_id) }; - + // Test: User1 destroys their own capability ts::next_tx(&mut scenario, user1); { let mut trail = ts::take_shared>(&scenario); let cap1 = ts::take_from_sender(&scenario); - + // Verify both capabilities are tracked before destruction let cap_count_before = trail.issued_capabilities().size(); assert!(trail.issued_capabilities().contains(&cap1_id), 0); assert!(trail.issued_capabilities().contains(&cap2_id), 1); - + // Destroy the capability trail.destroy_capability(cap1); - + // Verify capability was removed from tracking assert!(trail.issued_capabilities().size() == cap_count_before - 1, 2); assert!(!trail.issued_capabilities().contains(&cap1_id), 3); - + // Verify other capability is still tracked assert!(trail.issued_capabilities().contains(&cap2_id), 4); - + ts::return_shared(trail); }; - + // Verify cap1 is no longer available to user1 ts::next_tx(&mut scenario, user1); { // This should not find cap1 since it was destroyed assert!(!ts::has_most_recent_for_sender(&scenario), 5); }; - + // Test: User2 destroys their own capability ts::next_tx(&mut scenario, user2); { let mut trail = ts::take_shared>(&scenario); let cap2 = ts::take_from_sender(&scenario); - + let cap_count_before = trail.issued_capabilities().size(); - + trail.destroy_capability(cap2); - + // Verify capability was removed from tracking assert!(trail.issued_capabilities().size() == cap_count_before - 1, 6); assert!(!trail.issued_capabilities().contains(&cap2_id), 7); - + ts::return_shared(trail); }; - + // Verify only admin capability remains ts::next_tx(&mut scenario, admin_user); { let trail = ts::take_shared>(&scenario); - + // Only the initial admin capability should remain assert!(trail.issued_capabilities().size() == 1, 8); - + ts::return_shared(trail); }; - + ts::end(scenario); } @@ -410,56 +412,56 @@ fun test_capability_lifecycle() { let admin_user = @0xAD; let record_admin_user = @0xB0B; let role_admin_user = @0xCAB; - + let mut scenario = ts::begin(admin_user); - + // Setup: Create audit trail let trail_id = { let locking_config = locking::new(locking::window_count_based(0)); - + let (admin_cap, trail_id) = setup_test_audit_trail( &mut scenario, locking_config, - std::option::none() + std::option::none(), ); - + transfer::public_transfer(admin_cap, admin_user); trail_id }; - + // Create roles ts::next_tx(&mut scenario, admin_user); { let admin_cap = ts::take_from_sender(&scenario); let mut trail = ts::take_shared>(&scenario); - + // Initially only admin cap should be tracked assert!(trail.issued_capabilities().size() == 1, 0); - + trail.create_role( &admin_cap, string::utf8(b"RecordAdmin"), permission::record_admin_permissions(), ts::ctx(&mut scenario), ); - + trail.create_role( &admin_cap, string::utf8(b"RoleAdmin"), permission::role_admin_permissions(), ts::ctx(&mut scenario), ); - + ts::return_to_sender(&scenario, admin_cap); ts::return_shared(trail); }; - + // Issue capabilities ts::next_tx(&mut scenario, admin_user); let (record_cap_id, role_cap_id) = { let admin_cap = ts::take_from_sender(&scenario); let mut trail = ts::take_shared>(&scenario); - + let record_cap = trail.new_capability( &admin_cap, &string::utf8(b"RecordAdmin"), @@ -467,7 +469,7 @@ fun test_capability_lifecycle() { ); let record_cap_id = object::id(&record_cap); transfer::public_transfer(record_cap, record_admin_user); - + let role_cap = trail.new_capability( &admin_cap, &string::utf8(b"RoleAdmin"), @@ -475,27 +477,27 @@ fun test_capability_lifecycle() { ); let role_cap_id = object::id(&role_cap); transfer::public_transfer(role_cap, role_admin_user); - + // Verify all capabilities are tracked assert!(trail.issued_capabilities().size() == 3, 1); // admin + record + role assert!(trail.issued_capabilities().contains(&record_cap_id), 2); assert!(trail.issued_capabilities().contains(&role_cap_id), 3); - + ts::return_to_sender(&scenario, admin_cap); ts::return_shared(trail); - + (record_cap_id, role_cap_id) }; - + // Use RecordAdmin capability to add a record ts::next_tx(&mut scenario, record_admin_user); { let mut trail = ts::take_shared>(&scenario); let record_cap = ts::take_from_sender(&scenario); - + let mut clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); clock.set_for_testing(test_utils::initial_time_for_testing() + 1000); - + let test_data = test_utils::new_test_data(1, b"Test record"); trail.add_record( &record_cap, @@ -504,47 +506,47 @@ fun test_capability_lifecycle() { &clock, ts::ctx(&mut scenario), ); - + iota::clock::destroy_for_testing(clock); ts::return_to_sender(&scenario, record_cap); ts::return_shared(trail); }; - + // RecordAdmin destroys their capability ts::next_tx(&mut scenario, record_admin_user); { let mut trail = ts::take_shared>(&scenario); let record_cap = ts::take_from_sender(&scenario); - + trail.destroy_capability(record_cap); - + // Verify capability was removed assert!(trail.issued_capabilities().size() == 2, 4); // admin + role assert!(!trail.issued_capabilities().contains(&record_cap_id), 5); - + ts::return_shared(trail); }; - + // Admin revokes RoleAdmin capability ts::next_tx(&mut scenario, role_admin_user); { let admin_cap = ts::take_from_address(&scenario, admin_user); let mut trail = ts::take_shared>(&scenario); let role_cap = ts::take_from_sender(&scenario); - + trail.revoke_capability( &admin_cap, role_cap.id(), ); - + // Verify capability was removed assert!(trail.issued_capabilities().size() == 1, 6); // only admin remains assert!(!trail.issued_capabilities().contains(&role_cap_id), 7); - + ts::return_to_address(admin_user, admin_cap); ts::return_to_sender(&scenario, role_cap); ts::return_shared(trail); }; - + ts::end(scenario); -} \ No newline at end of file +} diff --git a/audit-trail-move/tests/create_audit_trail_tests.move b/audit-trail-move/tests/create_audit_trail_tests.move index 6de3b79d..6ced54ad 100644 --- a/audit-trail-move/tests/create_audit_trail_tests.move +++ b/audit-trail-move/tests/create_audit_trail_tests.move @@ -2,13 +2,14 @@ /// This module contains comprehensive tests for the AuditTrail creation functionality. module audit_trail::create_audit_trail_tests; -use audit_trail::main::{Self, AuditTrail, initial_admin_role_name}; -use audit_trail::locking::{Self}; -use audit_trail::capability::{Capability}; -use audit_trail::test_utils::{setup_test_audit_trail, new_test_data, initial_time_for_testing, TestData}; -use iota::test_scenario::{Self as ts}; -use iota::clock::{Self}; -use std::string::{Self}; +use audit_trail::{ + capability::Capability, + locking, + main::{Self, AuditTrail, initial_admin_role_name}, + test_utils::{setup_test_audit_trail, new_test_data, initial_time_for_testing, TestData} +}; +use iota::{clock, test_scenario as ts}; +use std::string; /// Goals of this test: /// - Verifies creating an AuditTrail with no initial record @@ -18,37 +19,37 @@ use std::string::{Self}; fun test_create_without_initial_record() { let user = @0xA; let mut scenario = ts::begin(user); - + { let locking_config = locking::new(locking::window_count_based(0)); - + let (admin_cap, trail_id) = setup_test_audit_trail( &mut scenario, locking_config, - std::option::none() + std::option::none(), ); - + // Verify capability was created assert!(admin_cap.role() == initial_admin_role_name(), 0); assert!(admin_cap.trail_id() == trail_id, 1); - + // Clean up admin_cap.destroy_for_testing(); }; - + ts::next_tx(&mut scenario, user); { let trail = ts::take_shared>(&scenario); - + // Verify trail was created correctly assert!(trail.trail_creator() == user, 2); assert!(trail.trail_created_at() == initial_time_for_testing(), 3); assert!(trail.trail_record_count() == 0, 4); - + ts::return_shared(trail); }; - - ts::end(scenario); + + ts::end(scenario); } /// Goals of this test: @@ -59,40 +60,40 @@ fun test_create_without_initial_record() { fun test_create_with_initial_record() { let user = @0xB; let mut scenario = ts::begin(user); - + { let locking_config = locking::new(locking::window_time_based(86400)); // 1 day in seconds let initial_data = new_test_data(42, b"Hello, World!"); - + let (admin_cap, trail_id) = setup_test_audit_trail( &mut scenario, locking_config, - std::option::some(initial_data) + std::option::some(initial_data), ); - + // Verify capability assert!(admin_cap.role() == initial_admin_role_name(), 0); assert!(admin_cap.trail_id() == trail_id, 1); - + // Clean up admin_cap.destroy_for_testing(); }; - + ts::next_tx(&mut scenario, user); { let trail = ts::take_shared>(&scenario); - + // Verify trail with initial record assert!(trail.trail_creator() == user, 2); assert!(trail.trail_created_at() == initial_time_for_testing(), 3); assert!(trail.trail_record_count() == 1, 4); - + // Verify the initial record exists assert!(trail.trail_has_record(0), 5); - + ts::return_shared(trail); }; - + ts::end(scenario); } @@ -104,17 +105,17 @@ fun test_create_with_initial_record() { fun test_create_minimal_metadata() { let user = @0xC; let mut scenario = ts::begin(user); - + { let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); clock.set_for_testing(3000); - + let locking_config = locking::new(locking::window_count_based(0)); let trail_metadata = main::new_trail_metadata( std::option::none(), std::option::none(), ); - + let (admin_cap, _trail_id) = main::create( std::option::none(), std::option::none(), @@ -124,27 +125,27 @@ fun test_create_minimal_metadata() { &clock, ts::ctx(&mut scenario), ); - + // Verify capability was created assert!(admin_cap.role() == initial_admin_role_name(), 0); - + // Clean up admin_cap.destroy_for_testing(); clock::destroy_for_testing(clock); }; - + ts::next_tx(&mut scenario, user); { let trail = ts::take_shared>(&scenario); - + // Verify trail was created assert!(trail.trail_creator() == user, 1); assert!(trail.trail_created_at() == 3000, 2); assert!(trail.trail_record_count() == 0, 3); - + ts::return_shared(trail); }; - + ts::end(scenario); } @@ -156,30 +157,30 @@ fun test_create_minimal_metadata() { fun test_create_with_locking_enabled() { let user = @0xD; let mut scenario = ts::begin(user); - - { + + { let locking_config = locking::new(locking::window_time_based(604800)); // 7 days in seconds let (admin_cap, _trail_id) = setup_test_audit_trail( &mut scenario, locking_config, - std::option::none() + std::option::none(), ); - + // Clean up admin_cap.destroy_for_testing(); }; - + ts::next_tx(&mut scenario, user); { let trail = ts::take_shared>(&scenario); - + // Verify trail with locking enabled assert!(trail.trail_creator() == user, 0); assert!(trail.trail_record_count() == 0, 1); - + ts::return_shared(trail); }; - + ts::end(scenario); } @@ -191,46 +192,46 @@ fun test_create_with_locking_enabled() { fun test_create_multiple_trails() { let user = @0xE; let mut scenario = ts::begin(user); - + let mut trail_ids = vector::empty(); - + // Create first trail - { + { let locking_config = locking::new(locking::window_count_based(0)); let (admin_cap1, trail_id1) = setup_test_audit_trail( &mut scenario, locking_config, - std::option::none() + std::option::none(), ); - + trail_ids.push_back(trail_id1); admin_cap1.destroy_for_testing(); }; - + ts::next_tx(&mut scenario, user); - + // Create second trail { let locking_config = locking::new(locking::window_count_based(0)); let (admin_cap2, trail_id2) = setup_test_audit_trail( &mut scenario, locking_config, - std::option::none() + std::option::none(), ); - + trail_ids.push_back(trail_id2); - + // Verify trails have different IDs assert!(trail_ids[0] != trail_ids[1], 0); - + admin_cap2.destroy_for_testing(); }; - + ts::end(scenario); } /// Test creating a MetadataAdmin role with metadata_admin_permissions. -/// +/// /// This test verifies that: /// 1. A creator can create an AuditTrail and receive an admin capability /// 2. The admin capability can be transferred to another user @@ -241,54 +242,66 @@ fun test_create_metadata_admin_role() { let creator = @0xA; let user = @0xB; let mut scenario = ts::begin(creator); - + // Creator creates the audit trail - { + { let locking_config = locking::new(locking::window_count_based(0)); - + let (admin_cap, trail_id) = setup_test_audit_trail( &mut scenario, locking_config, - std::option::none() + std::option::none(), ); - + // Verify admin capability was created assert!(admin_cap.role() == initial_admin_role_name(), 0); assert!(admin_cap.trail_id() == trail_id, 1); - + // Transfer the admin capability to the user - transfer::public_transfer(admin_cap, user); + transfer::public_transfer(admin_cap, user); }; - + // User receives the capability and creates the MetadataAdmin role ts::next_tx(&mut scenario, user); { let admin_cap = ts::take_from_sender(&scenario); let mut trail = ts::take_shared>(&scenario); - + // Create the MetadataAdmin role using the admin capability let metadata_admin_role_name = string::utf8(b"MetadataAdmin"); let metadata_admin_perms = audit_trail::permission::metadata_admin_permissions(); - + trail.create_role( &admin_cap, metadata_admin_role_name, metadata_admin_perms, ts::ctx(&mut scenario), ); - + // Verify the role was created by fetching its permissions let role_perms = trail.get_role_permissions(&string::utf8(b"MetadataAdmin")); - + // Verify the role has the correct permissions - assert!(audit_trail::permission::has_permission(role_perms, &audit_trail::permission::update_metadata()), 2); - assert!(audit_trail::permission::has_permission(role_perms, &audit_trail::permission::delete_metadata()), 3); + assert!( + audit_trail::permission::has_permission( + role_perms, + &audit_trail::permission::update_metadata(), + ), + 2, + ); + assert!( + audit_trail::permission::has_permission( + role_perms, + &audit_trail::permission::delete_metadata(), + ), + 3, + ); assert!(iota::vec_set::size(role_perms) == 2, 4); - + // Clean up ts::return_to_sender(&scenario, admin_cap); ts::return_shared(trail); }; - + ts::end(scenario); } diff --git a/audit-trail-move/tests/permission_tests.move b/audit-trail-move/tests/permission_tests.move index c7932851..3b0e5bd6 100644 --- a/audit-trail-move/tests/permission_tests.move +++ b/audit-trail-move/tests/permission_tests.move @@ -1,7 +1,7 @@ #[test_only] module audit_trail::permission_tests; -use audit_trail::permission::{Self}; +use audit_trail::permission; use iota::vec_set; #[test] @@ -15,7 +15,7 @@ fun test_has_permission_single_permission() { let mut set = permission::empty(); let perm = permission::add_record(); permission::add(&mut set, perm); - + assert!(permission::has_permission(&set, &perm), 0); } @@ -23,7 +23,7 @@ fun test_has_permission_single_permission() { fun test_has_permission_not_in_set() { let mut set = permission::empty(); permission::add(&mut set, permission::add_record()); - + let perm = permission::delete_record(); assert!(!permission::has_permission(&set, &perm), 0); } @@ -34,7 +34,7 @@ fun test_has_permission_multiple_permission() { permission::add(&mut set, permission::add_record()); permission::add(&mut set, permission::delete_record()); permission::add(&mut set, permission::delete_audit_trail()); - + assert!(permission::has_permission(&set, &permission::add_record()), 0); assert!(permission::has_permission(&set, &permission::delete_record()), 0); assert!(permission::has_permission(&set, &permission::delete_audit_trail()), 0); @@ -49,7 +49,7 @@ fun test_has_permission_from_vec() { permission::update_metadata(), ]; let set = permission::from_vec(perms); - + assert!(permission::has_permission(&set, &permission::add_record()), 0); assert!(permission::has_permission(&set, &permission::delete_record()), 0); assert!(permission::has_permission(&set, &permission::update_metadata()), 0); @@ -60,7 +60,7 @@ fun test_has_permission_from_vec() { fun test_from_vec_empty() { let perms = vector[]; let set = permission::from_vec(perms); - + assert!(vec_set::size(&set) == 0, 0); } @@ -68,7 +68,7 @@ fun test_from_vec_empty() { fun test_from_vec_single_permission() { let perms = vector[permission::add_record()]; let set = permission::from_vec(perms); - + assert!(vec_set::size(&set) == 1, 0); assert!(permission::has_permission(&set, &permission::add_record()), 0); } @@ -81,7 +81,7 @@ fun test_from_vec_multiple_permission() { permission::delete_audit_trail(), ]; let set = permission::from_vec(perms); - + assert!(vec_set::size(&set) == 3, 0); assert!(permission::has_permission(&set, &permission::add_record()), 0); assert!(permission::has_permission(&set, &permission::delete_record()), 0); @@ -92,7 +92,7 @@ fun test_from_vec_multiple_permission() { #[test] fun test_metadata_admin_permissions() { let perms = permission::metadata_admin_permissions(); - + assert!(permission::has_permission(&perms, &permission::update_metadata()), 0); assert!(permission::has_permission(&perms, &permission::delete_metadata()), 0); assert!(iota::vec_set::size(&perms) == 2, 0); @@ -110,4 +110,4 @@ fun test_from_vec_duplicate_permission() { let set = permission::from_vec(perms); // The following line should not be reached due to the expected failure assert!(vec_set::size(&set) == 2, 0); -} \ No newline at end of file +} diff --git a/audit-trail-move/tests/role_tests.move b/audit-trail-move/tests/role_tests.move index e0fc15cc..c2bf1ce7 100644 --- a/audit-trail-move/tests/role_tests.move +++ b/audit-trail-move/tests/role_tests.move @@ -1,14 +1,15 @@ #[test_only] module audit_trail::role_tests; -use audit_trail::permission::{Self}; -use audit_trail::locking::{Self}; -use audit_trail::main::{Self, AuditTrail, initial_admin_role_name}; -use audit_trail::test_utils::{Self, TestData, setup_test_audit_trail}; -use iota::test_scenario::{Self as ts}; -use iota::clock::{Self}; -use std::string::{Self}; -use audit_trail::capability::Capability; +use audit_trail::{ + capability::Capability, + locking, + main::{Self, AuditTrail, initial_admin_role_name}, + permission, + test_utils::{Self, TestData, setup_test_audit_trail} +}; +use iota::{clock, test_scenario as ts}; +use std::string; /// Test comprehensive role-based access control delegation workflow. /// @@ -31,35 +32,35 @@ fun test_role_based_permission_delegation() { let role_admin_user = @0xB0B; let cap_admin_user = @0xCAB; let record_admin_user = @0xDED; - + let mut scenario = ts::begin(admin_user); - + // Step 1: admin_user creates the audit trail let trail_id = { let locking_config = locking::new(locking::window_count_based(0)); - + let (admin_cap, trail_id) = setup_test_audit_trail( &mut scenario, locking_config, - std::option::none() + std::option::none(), ); - + // Verify admin capability was created with correct role and trail reference assert!(admin_cap.role() == initial_admin_role_name(), 0); assert!(admin_cap.trail_id() == trail_id, 1); - + // Transfer the admin capability to the user - transfer::public_transfer(admin_cap, admin_user); + transfer::public_transfer(admin_cap, admin_user); trail_id }; - + // Step 2: Admin creates RoleAdmin and CapAdmin roles ts::next_tx(&mut scenario, admin_user); { let admin_cap = ts::take_from_sender(&scenario); let mut trail = ts::take_shared>(&scenario); - + // Verify initial state - should only have the initial admin role assert!(trail.roles().size() == 1, 2); @@ -80,7 +81,7 @@ fun test_role_based_permission_delegation() { cap_admin_perms, ts::ctx(&mut scenario), ); - + // Verify both roles were created assert!(trail.roles().size() == 3, 3); // Initial admin + RoleAdmin + CapAdmin assert!(trail.has_role(&string::utf8(b"RoleAdmin")), 4); @@ -89,19 +90,19 @@ fun test_role_based_permission_delegation() { ts::return_to_sender(&scenario, admin_cap); ts::return_shared(trail); }; - + // Step 3: Admin creates capability for RoleAdmin and CapAdmin and transfers to the respective users ts::next_tx(&mut scenario, admin_user); { let admin_cap = ts::take_from_sender(&scenario); let mut trail = ts::take_shared>(&scenario); - + let role_admin_cap = trail.new_capability( &admin_cap, &string::utf8(b"RoleAdmin"), ts::ctx(&mut scenario), ); - + // Verify the capability was created with correct role and trail ID assert!(role_admin_cap.role() == string::utf8(b"RoleAdmin"), 6); assert!(role_admin_cap.trail_id() == trail_id, 7); @@ -113,7 +114,7 @@ fun test_role_based_permission_delegation() { &string::utf8(b"CapAdmin"), ts::ctx(&mut scenario), ); - + // Verify the capability was created with correct role and trail ID assert!(cap_admin_cap.role() == string::utf8(b"CapAdmin"), 8); assert!(cap_admin_cap.trail_id() == trail_id, 9); @@ -124,13 +125,12 @@ fun test_role_based_permission_delegation() { ts::return_shared(trail); }; - // Step 5: RoleAdmin creates RecordAdmin role (demonstrating delegated role management) ts::next_tx(&mut scenario, role_admin_user); { let mut trail = ts::take_shared>(&scenario); let role_admin_cap = ts::take_from_sender(&scenario); - + // Verify RoleAdmin has the correct role assert!(role_admin_cap.role() == string::utf8(b"RoleAdmin"), 10); @@ -141,7 +141,7 @@ fun test_role_based_permission_delegation() { record_admin_perms, ts::ctx(&mut scenario), ); - + // Verify RecordAdmin role was created successfully assert!(trail.roles().size() == 4, 11); // Initial admin + RoleAdmin + CapAdmin + RecordAdmin assert!(trail.has_role(&string::utf8(b"RecordAdmin")), 12); @@ -149,7 +149,7 @@ fun test_role_based_permission_delegation() { ts::return_to_sender(&scenario, role_admin_cap); ts::return_shared(trail); }; - + // Step 6: CapAdmin creates capability for RecordAdmin and transfers to record_admin_user ts::next_tx(&mut scenario, cap_admin_user); { @@ -164,7 +164,7 @@ fun test_role_based_permission_delegation() { &string::utf8(b"RecordAdmin"), ts::ctx(&mut scenario), ); - + // Verify the capability was created with correct role and trail ID assert!(record_admin_cap.role() == string::utf8(b"RecordAdmin"), 14); assert!(record_admin_cap.trail_id() == trail_id, 15); @@ -174,7 +174,7 @@ fun test_role_based_permission_delegation() { ts::return_to_sender(&scenario, cap_admin_cap); ts::return_shared(trail); }; - + // Step 7: RecordAdmin adds a new record to the audit trail (demonstrating delegated record management) ts::next_tx(&mut scenario, record_admin_user); { @@ -189,7 +189,7 @@ fun test_role_based_permission_delegation() { let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); clock.set_for_testing(test_utils::initial_time_for_testing() + 1000); - + let test_data = test_utils::new_test_data(42, b"Test record added by RecordAdmin"); trail.add_record( @@ -199,7 +199,7 @@ fun test_role_based_permission_delegation() { &clock, ts::ctx(&mut scenario), ); - + // Verify the record was added successfully assert!(trail.records().length() == initial_record_count + 1, 17); @@ -207,8 +207,8 @@ fun test_role_based_permission_delegation() { ts::return_to_sender(&scenario, record_admin_cap); ts::return_shared(trail); }; - + // Cleanup ts::next_tx(&mut scenario, admin_user); ts::end(scenario); -} \ No newline at end of file +} diff --git a/audit-trail-move/tests/test_utils.move b/audit-trail-move/tests/test_utils.move index 640b2198..ee729f22 100644 --- a/audit-trail-move/tests/test_utils.move +++ b/audit-trail-move/tests/test_utils.move @@ -1,18 +1,14 @@ - #[test_only] module audit_trail::test_utils; -use audit_trail::locking::{Self}; -use audit_trail::capability::{Capability}; -use iota::clock::{Self}; -use iota::test_scenario::{Self as ts, Scenario}; -use audit_trail::main::{Self}; -use std::string::{Self}; +use audit_trail::{capability::Capability, locking, main}; +use iota::{clock, test_scenario::{Self as ts, Scenario}}; +use std::string; const INITIAL_TIME_FOR_TESTING: u64 = 1234; /// Test data type for audit trail records -public struct TestData has store, copy, drop { +public struct TestData has copy, drop, store { value: u64, message: vector, } @@ -29,16 +25,20 @@ public(package) fun initial_time_for_testing(): u64 { } /// Setup a test audit trail with optional initial data -public(package) fun setup_test_audit_trail(scenario: &mut Scenario, locking_config: locking::LockingConfig, initial_data: Option): (Capability, iota::object::ID) { +public(package) fun setup_test_audit_trail( + scenario: &mut Scenario, + locking_config: locking::LockingConfig, + initial_data: Option, +): (Capability, iota::object::ID) { let (admin_cap, trail_id) = { let mut clock = clock::create_for_testing(ts::ctx(scenario)); clock.set_for_testing(INITIAL_TIME_FOR_TESTING); - + let trail_metadata = main::new_trail_metadata( std::option::some(string::utf8(b"Setup Test Trail")), std::option::none(), ); - + let (admin_cap, trail_id) = main::create( initial_data, std::option::none(), @@ -48,10 +48,10 @@ public(package) fun setup_test_audit_trail(scenario: &mut Scenario, locking_conf &clock, ts::ctx(scenario), ); - + clock::destroy_for_testing(clock); (admin_cap, trail_id) }; - + (admin_cap, trail_id) -} \ No newline at end of file +} From 71d2dd4aec2a2f811e073eb55c52302b2bf28988 Mon Sep 17 00:00:00 2001 From: chrisgitiota Date: Mon, 5 Jan 2026 14:54:33 +0100 Subject: [PATCH 023/189] New Capability restrictions: issued_to, valid_until and valid_from Also split of the role and capability management from the AT main module to allow reuse with other products. --- audit-trail-move/sources/audit_trail.move | 230 +--- audit-trail-move/sources/capability.move | 182 ++- audit-trail-move/sources/role_map.move | 610 +++++++++ audit-trail-move/tests/capability_tests.move | 1095 +++++++++++++---- .../tests/create_audit_trail_tests.move | 21 +- audit-trail-move/tests/role_tests.move | 74 +- audit-trail-move/tests/test_utils.move | 31 +- notarization-move/sources/timelock.move | 2 +- 8 files changed, 1782 insertions(+), 463 deletions(-) create mode 100644 audit-trail-move/sources/role_map.move diff --git a/audit-trail-move/sources/audit_trail.move b/audit-trail-move/sources/audit_trail.move index 2ff77a6e..21afa33e 100644 --- a/audit-trail-move/sources/audit_trail.move +++ b/audit-trail-move/sources/audit_trail.move @@ -9,28 +9,21 @@ /// Records are addressed by trail_id + sequence_number module audit_trail::main; -use audit_trail::capability::{Self, Capability}; +use audit_trail::capability::Capability; use audit_trail::locking::{Self, LockingConfig, LockingWindow, set_delete_record_lock}; use audit_trail::permission::{Self, Permission}; +use audit_trail::role_map::{Self, RoleMap}; use audit_trail::record::{Self, Record}; use iota::clock::{Self, Clock}; use iota::event; use iota::linked_table::{Self, LinkedTable}; -use iota::vec_map::{Self, VecMap}; -use iota::vec_set::{Self, VecSet}; use std::string::String; // ===== Errors ===== #[error] const ERecordNotFound: vector = b"Record not found at the given sequence number"; #[error] -const ERoleDoesNotExist: vector = b"The specified role does not exist in the `roles` map"; -#[error] const EPermissionDenied: vector = b"The role associated with the provided capability does not have the required permission"; -#[error] -const ECapabilityHasBeenRevoked: vector = b"The provided capability has been revoked and is no longer valid"; -#[error] -const ETrailIdNotCorrect: vector = b"The trail ID associated with the provided capability does not match the audit trail"; // ===== Constants ===== const INITIAL_ADMIN_ROLE_NAME: vector = b"Admin"; @@ -58,13 +51,11 @@ public struct AuditTrail has key, store { /// Deletion locking rules locking_config: LockingConfig, /// A list of role definitions consisting of a unique role specifier and a list of associated permissions - roles: VecMap>, + roles: RoleMap, /// Set at creation, cannot be changed immutable_metadata: TrailImmutableMetadata, /// Can be updated by holders of MetadataUpdate permission updatable_metadata: Option, - /// Whitelist of all issued capability IDs - issued_capabilities: VecSet, } // ===== Events ===== @@ -90,16 +81,6 @@ public struct RecordAdded has copy, drop { // TODO: Add event for Record deletion and (if part of MVP) correction -/// Emitted when a capability is issued -public struct CapabilityIssued has copy, drop { - trail_id: ID, - capability_id: ID, - role: String, - issued_to: address, - issued_by: address, - timestamp: u64, -} - // ===== Constructors ===== @@ -171,17 +152,25 @@ public fun create( initial_data.destroy_none(); }; - let mut roles = vec_map::empty>(); - roles.insert(initial_admin_role_name(), permission::admin_permissions()); + let role_admin_permissions = role_map::new_role_admin_permissions( + permission::add_roles(), + permission::delete_roles(), + permission::update_roles(), + ); - let admin_cap = capability::new_capability( - initial_admin_role_name(), - trail_id, - ctx, + let capability_admin_permissions = role_map::new_capability_admin_permissions( + permission::add_capabilities(), + permission::revoke_capabilities(), ); - let mut issued_capabilities = vec_set::empty(); - issued_capabilities.insert(admin_cap.id()); + let (roles, admin_cap) = role_map::new( + trail_id, + initial_admin_role_name(), + permission::admin_permissions(), + role_admin_permissions, + capability_admin_permissions, + ctx + ); let trail = AuditTrail { id: trail_uid, @@ -193,7 +182,6 @@ public fun create( roles, immutable_metadata: trail_metadata, updatable_metadata, - issued_capabilities, }; transfer::share_object(trail); @@ -225,7 +213,14 @@ public fun trail_add_record( clock: &Clock, ctx: &mut TxContext, ) { - assert!(trail.has_capability_permission(cap, &permission::add_record()), EPermissionDenied); + assert!(trail.roles.is_capability_valid( + cap, + &permission::add_record(), + clock, + ctx + ), + EPermissionDenied + ); let caller = ctx.sender(); let timestamp = clock::timestamp_ms(clock); @@ -278,9 +273,17 @@ public fun trail_update_locking_config( trail: &mut AuditTrail, cap: &Capability, new_config: LockingConfig, - _ctx: &mut TxContext, + clock: &Clock, + ctx: &TxContext, ) { - assert!(trail.has_capability_permission(cap, &permission::update_locking_config()), EPermissionDenied); + assert!(trail.roles.is_capability_valid( + cap, + &permission::update_locking_config(), + clock, + ctx + ), + EPermissionDenied + ); trail.locking_config = new_config; } @@ -289,9 +292,17 @@ public fun trail_update_locking_config_for_delete_record( trail: &mut AuditTrail, cap: &Capability, new_delete_record_lock: LockingWindow, - _ctx: &mut TxContext, + clock: &Clock, + ctx: &TxContext, ) { - assert!(trail.has_capability_permission(cap, &permission::update_locking_config_for_delete_record()), EPermissionDenied); + assert!(trail.roles.is_capability_valid( + cap, + &permission::update_locking_config_for_delete_record(), + clock, + ctx + ), + EPermissionDenied + ); set_delete_record_lock(&mut trail.locking_config, new_delete_record_lock); } @@ -300,9 +311,17 @@ public fun trail_update_metadata( trail: &mut AuditTrail, cap: &Capability, new_metadata: Option, - _ctx: &mut TxContext, + clock: &Clock, + ctx: &TxContext, ) { - assert!(trail.has_capability_permission(cap, &permission::update_metadata()), EPermissionDenied); + assert!(trail.roles.is_capability_valid( + cap, + &permission::update_metadata(), + clock, + ctx + ), + EPermissionDenied + ); trail.updatable_metadata = new_metadata; } @@ -380,128 +399,16 @@ public fun trail_has_record(trail: &AuditTrail, sequence_num public fun trail_records(trail: &AuditTrail): &LinkedTable> { &trail.records } +// ===== Role and Capability Functions ===== -// ===== Role related Functions ===== - -/// Get the permissions associated with a specific role. -/// Aborts with ERoleDoesNotExist if the role does not exist. -public fun trail_get_role_permissions( - trail: &AuditTrail, - role: &String, -): &VecSet { - assert!(vec_map::contains(&trail.roles, role), ERoleDoesNotExist); - vec_map::get(&trail.roles, role) -} - -/// Create a new role consisting of a role name and associated permissions -public fun trail_create_role( - trail: &mut AuditTrail, - cap: &Capability, - role: String, - permissions: VecSet, - _ctx: &mut TxContext, -) { - assert!(trail.has_capability_permission(cap, &permission::add_roles()), EPermissionDenied); - vec_map::insert(&mut trail.roles, role, permissions); -} - -/// Delete an existing role -public fun trail_delete_role( - trail: &mut AuditTrail, - cap: &Capability, - role: &String, - _ctx: &mut TxContext, -) { - assert!(trail.has_capability_permission(cap, &permission::delete_roles()), EPermissionDenied); - vec_map::remove(&mut trail.roles, role); -} - -/// Update permissions associated with an existing role -public fun trail_update_role_permissions( - trail: &mut AuditTrail, - cap: &Capability, - role: &String, - new_permissions: VecSet, - _ctx: &mut TxContext, -) { - assert!(trail.has_capability_permission(cap, &permission::update_roles()), EPermissionDenied); - assert!(vec_map::contains(&trail.roles, role), ERoleDoesNotExist); - vec_map::insert(&mut trail.roles, *role, new_permissions); -} - -/// Returns the roles defined in the audit trail -public fun trail_roles(trail: &AuditTrail): &VecMap> { +/// Returns a reference the RoleMap managing the roles and capabilities used in the audit trail +public fun trail_roles(trail: &AuditTrail): &RoleMap { &trail.roles } -/// Indicates if the specified role exists in the audit trail -public fun trail_has_role( - trail: &AuditTrail, - role: &String, -): bool { - vec_map::contains(&trail.roles, role) -} - -// ===== Capability related Functions ===== - -/// Indicates if a provided capability has a specific permission. -public fun trail_has_capability_permission( - trail: &AuditTrail, - cap: &Capability, - permission: &Permission, -): bool { - assert!(trail.id() == cap.trail_id(), ETrailIdNotCorrect); - assert!(trail.issued_capabilities.contains(&cap.id()), ECapabilityHasBeenRevoked); - let permissions = trail.get_role_permissions(cap.role()); - vec_set::contains(permissions, permission) -} - -/// Create a new capability with a specific role -/// Aborts with ERoleDoesNotExist if the role does not exist. -public fun trail_new_capability( - trail: &mut AuditTrail, - cap: &Capability, - role: &String, - ctx: &mut TxContext, -): Capability { - assert!(trail.has_capability_permission(cap, &permission::add_capabilities()), EPermissionDenied); - assert!(trail.roles.contains(role), ERoleDoesNotExist); - let new_cap = capability::new_capability( - *role, - trail.id(), - ctx, - ); - trail.issued_capabilities.insert(new_cap.id()); - new_cap -} - -/// Destroy an existing capability -/// Every owner of a capability is allowed to destroy it when no longer needed. -/// TODO: Clarify if we need to restrict access with the `CapabilitiesRevoke` permission here. -/// If yes, we also need a destroy function for Admin capabilities (without the need of another Admin capability). -/// Otherwise the last Admin capability holder will block the trail forever by not being able to destroy it. -public fun trail_destroy_capability( - trail: &mut AuditTrail, - cap_to_destroy: Capability, -) { - assert!(trail.id() == cap_to_destroy.trail_id(), ETrailIdNotCorrect); - trail.issued_capabilities.remove(&cap_to_destroy.id()); - cap_to_destroy.destroy(); -} - -public fun trail_revoke_capability( - trail: &mut AuditTrail, - cap: &Capability, - cap_to_revoke: ID, -) { - assert!(trail.has_capability_permission(cap, &permission::revoke_capabilities()), EPermissionDenied); - trail.issued_capabilities.remove(&cap_to_revoke); -} - -public fun trail_issued_capabilities( - trail: &AuditTrail, -): &VecSet { - &trail.issued_capabilities +/// Returns a mutable reference to the RoleMap managing the roles and capabilities used in the audit trail +public fun trail_roles_mut(trail: &mut AuditTrail): &mut RoleMap { + &mut trail.roles } // ===== public use statements ===== @@ -525,14 +432,5 @@ public use fun trail_first_sequence as AuditTrail.first_sequence; public use fun trail_last_sequence as AuditTrail.last_sequence; public use fun trail_get_record as AuditTrail.get_record; public use fun trail_has_record as AuditTrail.has_record; -public use fun trail_has_capability_permission as AuditTrail.has_capability_permission; -public use fun trail_new_capability as AuditTrail.new_capability; -public use fun trail_destroy_capability as AuditTrail.destroy_capability; -public use fun trail_revoke_capability as AuditTrail.revoke_capability; -public use fun trail_issued_capabilities as AuditTrail.issued_capabilities; -public use fun trail_get_role_permissions as AuditTrail.get_role_permissions; -public use fun trail_create_role as AuditTrail.create_role; -public use fun trail_delete_role as AuditTrail.delete_role; -public use fun trail_update_role_permissions as AuditTrail.update_role_permissions; public use fun trail_roles as AuditTrail.roles; -public use fun trail_has_role as AuditTrail.has_role; \ No newline at end of file +public use fun trail_roles_mut as AuditTrail.roles_mut; diff --git a/audit-trail-move/sources/capability.move b/audit-trail-move/sources/capability.move index bf53c962..ebed52b8 100644 --- a/audit-trail-move/sources/capability.move +++ b/audit-trail-move/sources/capability.move @@ -5,37 +5,125 @@ module audit_trail::capability; use std::string::String; +use iota::clock::{Self, Clock}; + +// ===== Errors ===== + +#[error] +const EValidityPeriodInconsistent: vector = b"Validity period is inconsistent: valid_from must be before valid_until"; // ===== Core Structures ===== -/// Capability granting role-based access to an audit trail +/// Capability granting role-based access to a managed onchain object (i.e. an audit trail) public struct Capability has key, store { id: UID, - trail_id: ID, - role: String + /// The ID of the onchain object this capability applies to + security_vault_id: ID, + /// The role granted by this capability + /// Arbitrary string specifying a role contained in the `role_map::RoleMap` mapping + role: String, + /// For whom has this capability been issued + /// * If Some(address), the capability is bound to that specific address + /// * If None, the capability is not bound to a specific address + issued_to: Option
, + /// Optional validity period start timestamp (in seconds since Unix epoch) + /// * The specified timestamp is included in the validity period + /// * If None, the capability is valid from creation time + valid_from: Option, + /// Optional validity period end timestamp (in seconds since Unix epoch) + /// * The specified timestamp is excluded in the validity period + /// * If None, the capability does not expire + valid_until: Option, } -/// Create a new capability with a specific role +/// Create a new capability with a specific role and all available optional restrictions +/// +/// Parameters: +/// * role: The role granted by this capability +/// * security_vault_id: The ID of onchain object (i.e. an audit trail) this capability applies to +/// * issued_to: Optional address restriction; if Some(address), the capability is bound to that specific address +/// * valid_from: Optional validity period start timestamp (in seconds since Unix epoch); if Some(ts), the capability is valid from that timestamp onwards +/// * valid_until: Optional validity period end timestamp (in seconds since Unix epoch); if Some(ts), the capability is valid until that timestamp +/// * ctx: The transaction context +/// +/// Returns: The newly created Capability +/// +/// Errors: +/// * EValidityPeriodInconsistent: If both valid_from and valid_until are provided and valid_from >= valid_until public(package) fun new_capability( role: String, - trail_id: ID, + security_vault_id: ID, + issued_to: Option
, + valid_from: Option, + valid_until: Option, ctx: &mut TxContext, -): Capability { - Capability { +): Capability { + if (valid_from.is_some() && valid_until.is_some()) { + let from = valid_from.borrow(); + let until = valid_until.borrow(); + assert!(*from < *until, EValidityPeriodInconsistent); + }; + Capability { id: object::new(ctx), role, - trail_id, + security_vault_id, + issued_to, + valid_from, + valid_until, } } -// TODO: Is this needed? What is a setup capability? -// -// /// Create a setup capability for trail initialization -// public fun new_setup_cap(ctx: &mut TxContext): Capability { -// Capability { -// id: object::new(ctx), -// } -// } +/// Create a new unrestricted capability with a specific role +public(package) fun new_capability_without_restrictions( + role: String, + security_vault_id: ID, + ctx: &mut TxContext, +): Capability { + Capability { + id: object::new(ctx), + role, + security_vault_id, + issued_to: std::option::none(), + valid_from: std::option::none(), + valid_until: std::option::none(), + } +} + +/// Create a new capability with a specific role and validity period, valid until the given timestamp +public(package) fun new_capability_valid_until( + role: String, + security_vault_id: ID, + valid_until: u64, + ctx: &mut TxContext, +): Capability { + Capability { + id: object::new(ctx), + role, + security_vault_id, + issued_to: std::option::none(), + valid_from: std::option::none(), + valid_until: std::option::some(valid_until), + } +} + +/// Create a new capability with a specific role, exclusively usable by a specific address and an optional +/// validity period, valid until the given timestamp +public(package) fun new_capability_for_address( + role: String, + security_vault_id: ID, + issued_to: address, + valid_until: Option, + ctx: &mut TxContext, +): Capability { + Capability { + id: object::new(ctx), + role, + security_vault_id, + issued_to: std::option::some(issued_to), + valid_from: std::option::none(), + valid_until, + } +} /// Get the capability's ID public fun cap_id(cap: &Capability): ID { @@ -47,9 +135,9 @@ public fun cap_role(cap: &Capability): &String { &cap.role } -/// Get the capability's trail ID -public fun cap_trail_id(cap: &Capability): ID { - cap.trail_id +/// Get the capability's security_vault_id +public fun cap_security_vault_id(cap: &Capability): ID { + cap.security_vault_id } /// Check if the capability has a specific role @@ -57,9 +145,54 @@ public fun cap_has_role(cap: &Capability, role: &String): bool { &cap.role == role } +// Get the capability's issued_to address +public fun cap_issued_to(cap: &Capability): &Option
{ + &cap.issued_to +} + +// Get the capability's valid_from timestamp +public fun cap_valid_from(cap: &Capability): &Option { + &cap.valid_from +} + +// Get the capability's valid_until timestamp +public fun cap_valid_until(cap: &Capability): &Option { + &cap.valid_until +} + +// Check if the capability is currently valid for `clock::timestamp_ms(clock)` +public fun cap_is_currently_valid(cap: &Capability, clock: &Clock,): bool { + let current_ts = clock::timestamp_ms(clock) / 1000; // convert to seconds + cap.is_valid_for_timestamp(current_ts) +} + +// Check if the capability is valid for a specific timestamp (in seconds since Unix epoch) +public fun cap_is_valid_for_timestamp(cap: &Capability, timestamp_secs: u64,): bool { + let valid_from_ok = if (cap.valid_from.is_some()) { + let from = cap.valid_from.borrow(); + timestamp_secs >= *from + } else { + true + }; + let valid_until_ok = if (cap.valid_until.is_some()) { + let until = cap.valid_until.borrow(); + timestamp_secs < *until + } else { + true + }; + valid_from_ok && valid_until_ok +} + /// Destroy a capability public(package) fun cap_destroy(cap: Capability) { - let Capability { id, role: _role, trail_id: _trail_id } = cap; + let Capability { + id, + role: _role, + security_vault_id: _trail_id, + issued_to: _issued_to, + valid_from: _valid_from, + valid_until: _valid_until, + } = cap; object::delete(id); } @@ -72,8 +205,13 @@ public fun cap_destroy_for_testing(cap: Capability) { public use fun cap_id as Capability.id; public use fun cap_role as Capability.role; -public use fun cap_trail_id as Capability.trail_id; +public use fun cap_security_vault_id as Capability.security_vault_id; public use fun cap_has_role as Capability.has_role; public use fun cap_destroy as Capability.destroy; +public use fun cap_issued_to as Capability.issued_to; +public use fun cap_valid_from as Capability.valid_from; +public use fun cap_valid_until as Capability.valid_until; +public use fun cap_is_currently_valid as Capability.is_currently_valid; +public use fun cap_is_valid_for_timestamp as Capability.is_valid_for_timestamp; #[test_only] -public use fun cap_destroy_for_testing as Capability.destroy_for_testing; \ No newline at end of file +public use fun cap_destroy_for_testing as Capability.destroy_for_testing; diff --git a/audit-trail-move/sources/role_map.move b/audit-trail-move/sources/role_map.move new file mode 100644 index 00000000..1e47f96a --- /dev/null +++ b/audit-trail-move/sources/role_map.move @@ -0,0 +1,610 @@ +// Copyright (c) 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +/// A role-based access control helper mapping unique role identifiers to their associated permissions. +/// +/// Provides the following functionalities: +/// - Define an initial role with a custom set of permissions (i.e. an Admin role). +/// - Use custom permission types defined by the integrating module using the generic parameter `P`. +/// - Create, delete, and update roles and their permissions +/// - Issue, revoke, and destroy `audit_trail::capability`s associated with a specific role. +/// - Validate `audit_trail::capability`s against the defined roles to facilitate proper access control by other modules +/// (function `RoleMap.is_capability_valid()`) +/// - All functions are access restricted by custom permissions defined during `RoleMap` instantiation. +/// +/// Examples: +/// - audit_trail::main module uses `RoleMap` to manage access to the audit trail records and their operations. + +module audit_trail::role_map; + +use std::string::String; +use iota::vec_map::{Self, VecMap}; +use iota::vec_set::{Self, VecSet}; +use audit_trail::capability::{Self, Capability}; +use iota::clock::Clock; +use iota::event; + +// =============== Errors ========================================================== + +#[error] +const EPermissionDenied: vector = b"The role associated with the provided capability does not have the required permission"; +#[error] +const ERoleDoesNotExist: vector = b"The specified role, directly specified or specified by a capability, does not exist in the `RoleMap` mapping"; +#[error] +const ECapabilityHasBeenRevoked: vector = b"The provided capability has been revoked and is no longer valid"; +#[error] +const ECapabilitySecurityVaultIdMismatch: vector = b"The security_vault_id associated with the provided capability does not match the security_vault_id of the `RoleMap`"; +#[error] +const ECapabilityTimeConstraintsNotMet: vector = b"The capability's time constraints are not currently met either due to `valid_from` or `valid_until` restrictions"; +#[error] +const ECapabilityIssuedToMismatch: vector = b"The capability is restricted to a specific address which does not match the caller's address"; +#[error] +const ECapabilityPermissionDenied: vector = b"The role associated with provided capability does not have the required permission"; + + +// =============== Events ========================================================== + +/// Emitted when a capability is issued +public struct CapabilityIssued has copy, drop { + security_vault_id: ID, + capability_id: ID, + role: String, + issued_to: Option
, + valid_from: Option, + valid_until: Option, +} + +/// Emitted when a capability is destroyed +public struct CapabilityDestroyed has copy, drop { + security_vault_id: ID, + capability_id: ID, + role: String, + issued_to: Option
, + valid_from: Option, + valid_until: Option, +} + +/// Emitted when a capability is revoked or destroyed +public struct CapabilityRevoked has copy, drop { + security_vault_id: ID, + capability_id: ID, +} + +// TODO: Add event for Role creation, removing, updating, etc. + + +// =============== Core Types ====================================================== + +/// Defines the permissions required to administer roles in this RoleMap +public struct RoleAdminPermissions has copy, drop, store { + /// Permission required to add a new role + add: P, + /// Permission required to delete an existing role + delete: P, + /// Permission required to update permissions associated with an existing role + update: P, +} + +/// Defines the permissions required to administer capabilities in this RoleMap +public struct CapabilityAdminPermissions has copy, drop, store { + /// Permission required to add (issue) a new capability + add: P, + /// Permission required to revoke an existing capability + revoke: P, +} + +/// The RoleMap structure mapping role names to their associated permissions +/// Generic parameter P defines the permission type used by the integrating module +/// (i.e. audit_trail::Permission) +public struct RoleMap has copy, drop, store { + /// The ObjectID of the onchain object integrating this RoleMap + security_vault_id: ID, + /// Mapping of role names to their associated permissions + roles: VecMap>, + /// Whitelist of all issued capability IDs + issued_capabilities: VecSet, + /// Permissions required to administer roles in this RoleMap + role_admin_permissions: RoleAdminPermissions

, + /// Permissions required to administer capabilities in this RoleMap + capability_admin_permissions: CapabilityAdminPermissions

, +} + +// =============== Role & Capability AdminPermissions Functions ==================== + +public fun new_role_admin_permissions( + add: P, + delete: P, + update: P, +): RoleAdminPermissions

{ + RoleAdminPermissions { + add, + delete, + update, + } +} + +public fun new_capability_admin_permissions( + add: P, + revoke: P, +): CapabilityAdminPermissions

{ + CapabilityAdminPermissions { + add, + revoke, + } +} + +// =============== RoleMap Functions =============================================== + +/// Create a new RoleMap with an initial admin role +/// The initial admin role is created with the specified name and permissions +/// An initial admin capability is created and returned alongside the RoleMap +/// The initial admin capability has no restrictions (no address, valid_from, or valid_until) +/// The security_vault_id is associated with both the RoleMap and the initial admin capability +/// Returns the newly created RoleMap and the initial admin capability +/// +/// Parameters +/// ---------- +/// - security_vault_id: +/// The security_vault_id to associate this RoleMap with the initial admin capability +/// and all other created capabilities. Set this to the ID of the onchain object that integrates the RoleMap. +/// - initial_admin_role_name: +/// The name of the initial admin role +/// - initial_admin_role_permissions: +/// The permissions associated with the initial admin role +/// - role_admin_permissions: +/// The permissions required to administer roles in this RoleMap +/// - capability_admin_permissions: +/// The permissions required to administer capabilities in this RoleMap +/// - ctx: +/// The transaction context for capability creation +public fun new( + security_vault_id: ID, + initial_admin_role_name: String, + initial_admin_role_permissions: VecSet

, + role_admin_permissions: RoleAdminPermissions

, + capability_admin_permissions: CapabilityAdminPermissions

, + ctx: &mut TxContext, +): (RoleMap

, Capability) { + let mut roles = vec_map::empty>(); + roles.insert(initial_admin_role_name, initial_admin_role_permissions); + + let admin_cap = capability::new_capability_without_restrictions( + initial_admin_role_name, + security_vault_id, + ctx, + ); + let mut issued_capabilities = vec_set::empty(); + issued_capabilities.insert(admin_cap.id()); + let role_map = RoleMap { + roles, + role_admin_permissions, + capability_admin_permissions, + security_vault_id, + issued_capabilities, + }; + + (role_map, admin_cap) +} + +/// Get the permissions associated with a specific role. +/// Aborts with ERoleDoesNotExist if the role does not exist. +public fun rmap_get_role_permissions( + role_map: &RoleMap

, + role: &String, +): &VecSet

{ + assert!(vec_map::contains(&role_map.roles, role), ERoleDoesNotExist); + vec_map::get(&role_map.roles, role) +} + +/// Create a new role consisting of a role name and associated permissions +public fun rmap_create_role( + role_map: &mut RoleMap

, + cap: &Capability, + role: String, + permissions: VecSet

, + clock: &Clock, + ctx: &TxContext, +) { + assert!(role_map.is_capability_valid( + cap, + &role_map.role_admin_permissions.add, + clock, + ctx + ), + EPermissionDenied + ); + + vec_map::insert(&mut role_map.roles, role, permissions); +} + +/// Delete an existing role +public fun rmap_delete_role( + role_map: &mut RoleMap

, + cap: &Capability, + role: &String, + clock: &Clock, + ctx: &TxContext, +) { + assert!(role_map.is_capability_valid( + cap, + &role_map.role_admin_permissions.delete, + clock, + ctx + ), + EPermissionDenied + ); + + vec_map::remove(&mut role_map.roles, role); +} + +/// Update permissions associated with an existing role +public fun rmap_update_role_permissions( + role_map: &mut RoleMap

, + cap: &Capability, + role: &String, + new_permissions: VecSet

, + clock: &Clock, + ctx: &TxContext, +) { + assert!(role_map.is_capability_valid( + cap, + &role_map.role_admin_permissions.update, + clock, + ctx + ), + EPermissionDenied + ); + + assert!(vec_map::contains(&role_map.roles, role), ERoleDoesNotExist); + vec_map::insert(&mut role_map.roles, *role, new_permissions); +} + +/// Indicates if the specified role exists in the role_map +public fun rmap_has_role( + role_map: &RoleMap

, + role: &String, +): bool { + vec_map::contains(&role_map.roles, role) +} + +// =============== Capability related Functions ==================================== + +/// Indicates if a provided capability is valid. +/// +/// A capability is considered valid if: +/// - The capability's security_vault_id matches the RoleMap's security_vault_id. +/// Aborts with ECapabilitySecurityVaultIdMismatch if not matching. +/// - The role value specified by the capability exists in the `RoleMap` mapping. +/// Aborts with ERoleDoesNotExist if the role does not exist. +/// - The role associated with the capability contains the permission specified by the `permission` argument. +/// Aborts with ECapabilityPermissionDenied if the permission is not granted by the role. +/// - The capability has not been revoked (is included in the `issued_capabilities` set). +/// Aborts with ECapabilityHasBeenRevoked if revoked. +/// - The capability is currently active, based on its time restrictions (if any). +/// Aborts with ECapabilityTimeConstraintsNotMet, if the current time is outside the valid_from and valid_until range. +/// - If the capability is restricted to a specific address, the caller's address matches the sender of the transaction. +/// Aborts with ECapabilityIssuedToMismatch if the addresses do not match. +/// +/// Parameters +/// ---------- +/// - role_map: Reference to the `RoleMap` mapping. +/// - cap: Reference to the capability to be validated. +/// - permission: The permission to check against the capability's role. +/// - clock: Reference to a Clock instance for time-based validation. +/// - ctx: Reference to the transaction context for accessing the caller's address. +/// +/// Returns +/// ------- +/// - bool: true if the capability is valid, otherwise aborts with the relevant error. +public fun rmap_is_capability_valid( + role_map: &RoleMap

, + cap: &Capability, + permission: &P, + clock: &Clock, + ctx: &TxContext, +): bool { + assert!(role_map.security_vault_id == cap.security_vault_id(), ECapabilitySecurityVaultIdMismatch); + + let permissions = role_map.get_role_permissions(cap.role()); + assert!(vec_set::contains(permissions, permission), ECapabilityPermissionDenied); + + assert!(role_map.issued_capabilities.contains(&cap.id()), ECapabilityHasBeenRevoked); + + if (cap.valid_from().is_some() || cap.valid_until().is_some()) { + assert!(cap.is_currently_valid(clock), ECapabilityTimeConstraintsNotMet); + }; + + if (cap.issued_to().is_some()) { + let caller = ctx.sender(); + let issued_to_addr = cap.issued_to().borrow(); + assert!(*issued_to_addr == caller, ECapabilityIssuedToMismatch); + }; + + true +} + +/// Create a new capability +/// +/// Parameters +/// ---------- +/// - role_map: Reference to the `RoleMap` mapping. +/// - cap: Reference to the capability used to authorize the creation of the new capability. +/// - role: The role to be assigned to the new capability. +/// - issued_to: Optional address restriction for the new capability. +/// - valid_from: Optional start time (in seconds since Unix epoch) for the new capability. +/// - valid_until: Optional end time (in seconds since Unix epoch) for the new capability. +/// - clock: Reference to a Clock instance for time-based validation. +/// - ctx: Reference to the transaction context. +/// +/// Returns the newly created capability. +/// +/// Sends a CapabilityIssued event upon successful creation. +/// +/// Errors: +/// - Aborts with EPermissionDenied if the provided capability does not have the permission specified with `CapabilityAdminPermissions::add`. +/// - Aborts with ERoleDoesNotExist if the specified role does not exist in the role_map. +/// - Aborts with audit_trail::capability::EValidityPeriodInconsistent if the provided valid_from and valid_until are inconsistent. +public fun rmap_new_capability( + role_map: &mut RoleMap

, + cap: &Capability, + role: &String, + issued_to: Option

, + valid_from: Option, + valid_until: Option, + clock: &Clock, + ctx: &mut TxContext, +): Capability { + assert!(role_map.is_capability_valid( + cap, + &role_map.capability_admin_permissions.add, + clock, + ctx + ), + EPermissionDenied + ); + + assert!(role_map.roles.contains(role), ERoleDoesNotExist); + let new_cap = capability::new_capability( + *role, + role_map.security_vault_id, + issued_to, + valid_from, + valid_until, + ctx, + ); + register_new_capability(role_map, &new_cap); + new_cap +} + +/// Create a new unrestricted capability with a specific role without any +/// address, valid_from, or valid_until restrictions. +/// +/// Returns the newly created capability. +/// +/// Sends a CapabilityIssued event upon successful creation. +/// +/// Errors: +/// - Aborts with EPermissionDenied if the provided capability does not have the permission specified with `CapabilityAdminPermissions::add`. +/// - Aborts with ERoleDoesNotExist if the specified role does not exist in the role_map. +public fun rmap_new_capability_without_restrictions( + role_map: &mut RoleMap

, + cap: &Capability, + role: &String, + clock: &Clock, + ctx: &mut TxContext, +): Capability { + assert!(role_map.is_capability_valid( + cap, + &role_map.capability_admin_permissions.add, + clock, + ctx + ), + EPermissionDenied + ); + + assert!(role_map.roles.contains(role), ERoleDoesNotExist); + let new_cap = capability::new_capability_without_restrictions( + *role, + role_map.security_vault_id, + ctx, + ); + + register_new_capability(role_map, &new_cap); + new_cap +} + +/// Create a new capability with a specific role that expires at a given timestamp (seconds since Unix epoch). +/// +/// Returns the newly created capability. +/// +/// Sends a CapabilityIssued event upon successful creation. +/// +/// Errors: +/// - Aborts with EPermissionDenied if the provided capability does not have the permission specified with `CapabilityAdminPermissions::add`. +/// - Aborts with ERoleDoesNotExist if the specified role does not exist in the role_map. +public fun rmap_new_capability_valid_until( + role_map: &mut RoleMap

, + cap: &Capability, + role: &String, + valid_until: u64, + clock: &Clock, + ctx: &mut TxContext, +): Capability { + assert!(role_map.is_capability_valid( + cap, + &role_map.capability_admin_permissions.add, + clock, + ctx + ), + EPermissionDenied + ); + + assert!(role_map.roles.contains(role), ERoleDoesNotExist); + let new_cap = capability::new_capability_valid_until( + *role, + role_map.security_vault_id, + valid_until, + ctx, + ); + + register_new_capability(role_map, &new_cap); + new_cap +} + +/// Create a new capability with a specific role restricted to an address. +/// Optionally set an expiration time (seconds since Unix epoch). +/// +/// Returns the newly created capability. +/// +/// Sends a CapabilityIssued event upon successful creation. +/// +/// Errors: +/// - Aborts with EPermissionDenied if the provided capability does not have the permission specified with `CapabilityAdminPermissions::add`. +/// - Aborts with ERoleDoesNotExist if the specified role does not exist in the role_map. +public fun rmap_new_capability_for_address( + role_map: &mut RoleMap

, + cap: &Capability, + role: &String, + issued_to: address, + valid_until: Option, + clock: &Clock, + ctx: &mut TxContext, +): Capability { + assert!(role_map.is_capability_valid( + cap, + &role_map.capability_admin_permissions.add, + clock, + ctx + ), + EPermissionDenied + ); + + assert!(role_map.roles.contains(role), ERoleDoesNotExist); + let new_cap = capability::new_capability_for_address( + *role, + role_map.security_vault_id, + issued_to, + valid_until, + ctx, + ); + + register_new_capability(role_map, &new_cap); + new_cap +} + +/// Destroy an existing capability +/// Every owner of a capability is allowed to destroy it when no longer needed. +/// +/// Sends a CapabilityDestroyed event upon successful destruction. +/// +/// TODO: Clarify if we need to restrict access with the `CapabilitiesRevoke` permission here. +/// If yes, we also need a destroy function for Admin capabilities (without the need of another Admin capability). +/// Otherwise the last Admin capability holder will block the role_map forever by not being able to destroy it. +public fun rmap_destroy_capability( + role_map: &mut RoleMap

, + cap_to_destroy: Capability, +) { + assert!(role_map.security_vault_id == cap_to_destroy.security_vault_id(), ECapabilitySecurityVaultIdMismatch); + role_map.issued_capabilities.remove(&cap_to_destroy.id()); + + event::emit(CapabilityDestroyed { + security_vault_id: role_map.security_vault_id, + capability_id: cap_to_destroy.id(), + role: *cap_to_destroy.role(), + issued_to: *cap_to_destroy.issued_to(), + valid_from: *cap_to_destroy.valid_from(), + valid_until: *cap_to_destroy.valid_until(), + }); + + cap_to_destroy.destroy(); +} + +/// Revoke an existing capability +/// +/// Sends a CapabilityRevoked event upon successful revocation. +/// +/// Errors: +/// - Aborts with EPermissionDenied if the provided capability does not have the permission specified with `CapabilityAdminPermissions::revoke`. +/// - Aborts with ERoleDoesNotExist if the specified role does not exist in the `RoleMap.issued_capabilities()` list. +public fun rmap_revoke_capability( + role_map: &mut RoleMap

, + cap: &Capability, + cap_to_revoke: ID, + clock: &Clock, + ctx: &TxContext, +) { + assert!(role_map.is_capability_valid( + cap, + &role_map.capability_admin_permissions.revoke, + clock, + ctx + ), + EPermissionDenied + ); + + assert!(role_map.issued_capabilities.contains(&cap_to_revoke), ERoleDoesNotExist); + role_map.issued_capabilities.remove(&cap_to_revoke); + + event::emit(CapabilityRevoked { + security_vault_id: role_map.security_vault_id, + capability_id: cap_to_revoke, + }); +} + +fun register_new_capability( + role_map: &mut RoleMap

, + new_cap: &Capability, +) { + role_map.issued_capabilities.insert(new_cap.id()); + + event::emit(CapabilityIssued { + security_vault_id: role_map.security_vault_id, + capability_id: new_cap.id(), + role: *new_cap.role(), + issued_to: *new_cap.issued_to(), + valid_from: *new_cap.valid_from(), + valid_until: *new_cap.valid_until(), + }); +} + +// =============== Getter Functions ================================================ + +/// Returns the size of the role_map, the number of managed roles +public fun rmap_size(role_map: &RoleMap

): u64 { + vec_map::size(&role_map.roles) +} + +/// Returns the security_vault_id associated with the role_map +public fun rmap_security_vault_id(role_map: &RoleMap

): ID { + role_map.security_vault_id +} + +//Returns the role admin permissions associated with the role_map +public fun rmap_role_admin_permissions(role_map: &RoleMap

): &RoleAdminPermissions

{ + &role_map.role_admin_permissions +} + +public fun rmap_issued_capabilities( + role_map: &RoleMap

, +): &VecSet { + &role_map.issued_capabilities +} + +// =============== public use statements =========================================== + +public use fun rmap_get_role_permissions as RoleMap.get_role_permissions; +public use fun rmap_create_role as RoleMap.create_role; +public use fun rmap_delete_role as RoleMap.delete_role; +public use fun rmap_update_role_permissions as RoleMap.update_role_permissions; +public use fun rmap_has_role as RoleMap.has_role; +public use fun rmap_size as RoleMap.size; +public use fun rmap_security_vault_id as RoleMap.security_vault_id; +public use fun rmap_role_admin_permissions as RoleMap.role_admin_permissions; +public use fun rmap_is_capability_valid as RoleMap.is_capability_valid; +public use fun rmap_new_capability as RoleMap.new_capability; +public use fun rmap_new_capability_without_restrictions as RoleMap.new_capability_without_restrictions; +public use fun rmap_new_capability_valid_until as RoleMap.new_capability_valid_until; +public use fun rmap_new_capability_for_address as RoleMap.new_capability_for_address; +public use fun rmap_destroy_capability as RoleMap.destroy_capability; +public use fun rmap_revoke_capability as RoleMap.revoke_capability; +public use fun rmap_issued_capabilities as RoleMap.issued_capabilities; diff --git a/audit-trail-move/tests/capability_tests.move b/audit-trail-move/tests/capability_tests.move index fea0f99f..a4974090 100644 --- a/audit-trail-move/tests/capability_tests.move +++ b/audit-trail-move/tests/capability_tests.move @@ -4,32 +4,64 @@ module audit_trail::capability_tests; use audit_trail::permission::{Self}; use audit_trail::locking::{Self}; use audit_trail::main::{AuditTrail}; -use audit_trail::test_utils::{Self, TestData, setup_test_audit_trail}; -use iota::test_scenario::{Self as ts}; +use audit_trail::test_utils::{Self, TestData, setup_test_audit_trail, fetch_capability_trail_and_clock, cleanup_capability_trail_and_clock}; +use iota::test_scenario::{Self as ts, Scenario}; use std::string::{Self}; use audit_trail::capability::Capability; -/// Test that new_capability() correctly creates a capability and tracks it in issued_capabilities. -/// -/// This test validates: -/// - Capability is created with correct role and trail ID -/// - Capability ID is added to the audit trail's issued_capabilities set -/// - Multiple capabilities can be issued and all are tracked -/// - Each capability has a unique ID -#[test] -fun test_new_capability() { - let admin_user = @0xAD; - let user1 = @0xB0B; - let user2 = @0xCAB; - - let mut scenario = ts::begin(admin_user); +/// Helper function to setup an audit trail with a RecordAdmin role and a capability +/// with a time window restriction transferred to the record_user. +/// Returns the trail_id. +fun setup_trail_with_record_admin_capability_and_time_window_restriction( + scenario: &mut Scenario, + admin_user: address, + record_user: address, + valid_from_secs: u64, + valid_until_secs: u64, +): ID { + // Setup + let trail_id = setup_trail_with_record_admin_role(scenario, admin_user); + // Issue capability with time window + ts::next_tx(scenario, admin_user); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(scenario); + + let cap = trail.roles_mut().new_capability( + &admin_cap, + &string::utf8(b"RecordAdmin"), + std::option::none(), // no address restriction + std::option::some(valid_from_secs), + std::option::some(valid_until_secs), + &clock, + ts::ctx(scenario), + ); + + // Verify capability properties + assert!(cap.issued_to().is_none(), 0); + assert!(cap.valid_from() == std::option::some(valid_from_secs), 1); + assert!(cap.valid_until() == std::option::some(valid_until_secs), 2); + + transfer::public_transfer(cap, record_user); + cleanup_capability_trail_and_clock(scenario, admin_cap, trail, clock); + }; + + trail_id +} + + +/// Helper function to setup an audit trail with a RecordAdmin role. +/// Returns the trail_id. +fun setup_trail_with_record_admin_role( + scenario: &mut Scenario, + admin_user: address, +): ID { // Setup: Create audit trail with admin capability let trail_id = { let locking_config = locking::new(locking::window_count_based(0)); let (admin_cap, trail_id) = setup_test_audit_trail( - &mut scenario, + scenario, locking_config, std::option::none() ); @@ -39,83 +71,104 @@ fun test_new_capability() { }; // Create a custom role for testing - ts::next_tx(&mut scenario, admin_user); + ts::next_tx(scenario, admin_user); { - let admin_cap = ts::take_from_sender(&scenario); - let mut trail = ts::take_shared>(&scenario); + let admin_cap = ts::take_from_sender(scenario); + let mut trail = ts::take_shared>(scenario); + let clock = iota::clock::create_for_testing(ts::ctx(scenario)); let record_admin_perms = permission::record_admin_permissions(); - trail.create_role( + trail.roles_mut().create_role( &admin_cap, string::utf8(b"RecordAdmin"), record_admin_perms, - ts::ctx(&mut scenario), + &clock, + ts::ctx(scenario), ); - ts::return_to_sender(&scenario, admin_cap); + iota::clock::destroy_for_testing(clock); + ts::return_to_sender(scenario, admin_cap); ts::return_shared(trail); }; + trail_id +} + +/// Test that new_capability() correctly creates a capability and tracks it in issued_capabilities. +/// +/// This test validates: +/// - Capability is created with correct role and trail ID +/// - Capability ID is added to the audit trail's issued_capabilities set +/// - Multiple capabilities can be issued and all are tracked +/// - Each capability has a unique ID +#[test] +fun test_new_capability() { + let admin_user = @0xAD; + let user1 = @0xB0B; + let user2 = @0xCAB; + + let mut scenario = ts::begin(admin_user); + + let trail_id = setup_trail_with_record_admin_role(&mut scenario, admin_user); + // Test: Issue first capability ts::next_tx(&mut scenario, admin_user); let cap1_id = { - let admin_cap = ts::take_from_sender(&scenario); - let mut trail = ts::take_shared>(&scenario); - + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + // Verify initial state - only admin capability should be tracked - let initial_cap_count = trail.issued_capabilities().size(); + let initial_cap_count = trail.roles().issued_capabilities().size(); assert!(initial_cap_count == 1, 0); // Only admin cap - let cap1 = trail.new_capability( + let cap1 = trail.roles_mut().new_capability_without_restrictions( &admin_cap, &string::utf8(b"RecordAdmin"), + &clock, ts::ctx(&mut scenario), ); // Verify capability was created correctly assert!(cap1.role() == string::utf8(b"RecordAdmin"), 1); - assert!(cap1.trail_id() == trail_id, 2); + assert!(cap1.security_vault_id() == trail_id, 2); let cap1_id = object::id(&cap1); // Verify capability ID is tracked in issued_capabilities - assert!(trail.issued_capabilities().size() == initial_cap_count + 1, 3); - assert!(trail.issued_capabilities().contains(&cap1_id), 4); - + assert!(trail.roles().issued_capabilities().size() == initial_cap_count + 1, 3); + assert!(trail.roles().issued_capabilities().contains(&cap1_id), 4); + transfer::public_transfer(cap1, user1); - ts::return_to_sender(&scenario, admin_cap); - ts::return_shared(trail); - + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + cap1_id }; // Test: Issue second capability ts::next_tx(&mut scenario, admin_user); let _cap2_id = { - let admin_cap = ts::take_from_sender(&scenario); - let mut trail = ts::take_shared>(&scenario); + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); - let previous_cap_count = trail.issued_capabilities().size(); + let previous_cap_count = trail.roles().issued_capabilities().size(); - let cap2 = trail.new_capability( + let cap2 = trail.roles_mut().new_capability_without_restrictions( &admin_cap, &string::utf8(b"RecordAdmin"), + &clock, ts::ctx(&mut scenario), ); let cap2_id = object::id(&cap2); // Verify both capabilities are tracked - assert!(trail.issued_capabilities().size() == previous_cap_count + 1, 5); - assert!(trail.issued_capabilities().contains(&cap1_id), 6); - assert!(trail.issued_capabilities().contains(&cap2_id), 7); + assert!(trail.roles().issued_capabilities().size() == previous_cap_count + 1, 5); + assert!(trail.roles().issued_capabilities().contains(&cap1_id), 6); + assert!(trail.roles().issued_capabilities().contains(&cap2_id), 7); // Verify capabilities have unique IDs assert!(cap1_id != cap2_id, 8); transfer::public_transfer(cap2, user2); - ts::return_to_sender(&scenario, admin_cap); - ts::return_shared(trail); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); cap2_id }; @@ -138,94 +191,63 @@ fun test_revoke_capability() { let mut scenario = ts::begin(admin_user); - // Setup: Create audit trail with admin capability - let _trail_id = { - let locking_config = locking::new(locking::window_count_based(0)); - - let (admin_cap, trail_id) = setup_test_audit_trail( - &mut scenario, - locking_config, - std::option::none() - ); - - transfer::public_transfer(admin_cap, admin_user); - trail_id - }; - - // Create a custom role for testing - ts::next_tx(&mut scenario, admin_user); - { - let admin_cap = ts::take_from_sender(&scenario); - let mut trail = ts::take_shared>(&scenario); - - let record_admin_perms = permission::record_admin_permissions(); - trail.create_role( - &admin_cap, - string::utf8(b"RecordAdmin"), - record_admin_perms, - ts::ctx(&mut scenario), - ); - - ts::return_to_sender(&scenario, admin_cap); - ts::return_shared(trail); - }; + let _trail_id = setup_trail_with_record_admin_role(&mut scenario, admin_user); // Issue two capabilities ts::next_tx(&mut scenario, admin_user); let (cap1_id, cap2_id) = { - let admin_cap = ts::take_from_sender(&scenario); - let mut trail = ts::take_shared>(&scenario); + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); - let cap1 = trail.new_capability( + let cap1 = trail.roles_mut().new_capability_without_restrictions( &admin_cap, &string::utf8(b"RecordAdmin"), + &clock, ts::ctx(&mut scenario), ); let cap1_id = object::id(&cap1); transfer::public_transfer(cap1, user1); - let cap2 = trail.new_capability( + let cap2 = trail.roles_mut().new_capability_without_restrictions( &admin_cap, &string::utf8(b"RecordAdmin"), + &clock, ts::ctx(&mut scenario), ); let cap2_id = object::id(&cap2); transfer::public_transfer(cap2, user2); - ts::return_to_sender(&scenario, admin_cap); - ts::return_shared(trail); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); (cap1_id, cap2_id) }; // Test: Revoke first capability - ts::next_tx(&mut scenario, user1); + ts::next_tx(&mut scenario, admin_user); { - let admin_cap = ts::take_from_address(&scenario, admin_user); - let mut trail = ts::take_shared>(&scenario); - let cap1 = ts::take_from_sender(&scenario); + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + let cap1 = ts::take_from_address(&scenario, user1); // Verify both capabilities are tracked before revocation - let cap_count_before = trail.issued_capabilities().size(); - assert!(trail.issued_capabilities().contains(&cap1_id), 0); - assert!(trail.issued_capabilities().contains(&cap2_id), 1); + let cap_count_before = trail.roles().issued_capabilities().size(); + assert!(trail.roles().issued_capabilities().contains(&cap1_id), 0); + assert!(trail.roles().issued_capabilities().contains(&cap2_id), 1); // Revoke the capability - trail.revoke_capability( + trail.roles_mut().revoke_capability( &admin_cap, - cap1.id() + cap1.id(), + &clock, + ts::ctx(&mut scenario), ); // Verify capability was removed from tracking - assert!(trail.issued_capabilities().size() == cap_count_before - 1, 2); - assert!(!trail.issued_capabilities().contains(&cap1_id), 3); - + assert!(trail.roles().issued_capabilities().size() == cap_count_before - 1, 2); + assert!(!trail.roles().issued_capabilities().contains(&cap1_id), 3); // Verify other capability is still tracked - assert!(trail.issued_capabilities().contains(&cap2_id), 4); + assert!(trail.roles().issued_capabilities().contains(&cap2_id), 4); - ts::return_to_address(admin_user, admin_cap); - ts::return_to_sender(&scenario, cap1); - ts::return_shared(trail); + ts::return_to_address(user1, cap1); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); }; // Verify cap1 is still available to user1 -it has been revoked, not destroyed @@ -236,26 +258,26 @@ fun test_revoke_capability() { }; // Test: Revoke second capability - ts::next_tx(&mut scenario, user2); + ts::next_tx(&mut scenario, admin_user); { - let admin_cap = ts::take_from_address(&scenario, admin_user); - let mut trail = ts::take_shared>(&scenario); - let cap2 = ts::take_from_sender(&scenario); + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + let cap2 = ts::take_from_address(&scenario, user2); - let cap_count_before = trail.issued_capabilities().size(); + let cap_count_before = trail.roles().issued_capabilities().size(); - trail.revoke_capability( + trail.roles_mut().revoke_capability( &admin_cap, - cap2.id() + cap2.id(), + &clock, + ts::ctx(&mut scenario), ); // Verify capability was removed from tracking - assert!(trail.issued_capabilities().size() == cap_count_before - 1, 6); - assert!(!trail.issued_capabilities().contains(&cap2_id), 7); - - ts::return_to_address(admin_user, admin_cap); - ts::return_to_sender(&scenario, cap2); - ts::return_shared(trail); + assert!(trail.roles().issued_capabilities().size() == cap_count_before - 1, 6); + assert!(!trail.roles().issued_capabilities().contains(&cap2_id), 7); + + ts::return_to_address(user2, cap2); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); }; ts::end(scenario); @@ -276,62 +298,32 @@ fun test_destroy_capability() { let mut scenario = ts::begin(admin_user); - // Setup: Create audit trail with admin capability - let trail_id = { - let locking_config = locking::new(locking::window_count_based(0)); - - let (admin_cap, trail_id) = setup_test_audit_trail( - &mut scenario, - locking_config, - std::option::none() - ); - - transfer::public_transfer(admin_cap, admin_user); - trail_id - }; - - // Create a custom role for testing - ts::next_tx(&mut scenario, admin_user); - { - let admin_cap = ts::take_from_sender(&scenario); - let mut trail = ts::take_shared>(&scenario); - - let record_admin_perms = permission::record_admin_permissions(); - trail.create_role( - &admin_cap, - string::utf8(b"RecordAdmin"), - record_admin_perms, - ts::ctx(&mut scenario), - ); - - ts::return_to_sender(&scenario, admin_cap); - ts::return_shared(trail); - }; + let _trail_id = setup_trail_with_record_admin_role(&mut scenario, admin_user); // Issue two capabilities ts::next_tx(&mut scenario, admin_user); let (cap1_id, cap2_id) = { - let admin_cap = ts::take_from_sender(&scenario); - let mut trail = ts::take_shared>(&scenario); + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); - let cap1 = trail.new_capability( + let cap1 = trail.roles_mut().new_capability_without_restrictions( &admin_cap, &string::utf8(b"RecordAdmin"), + &clock, ts::ctx(&mut scenario), ); let cap1_id = object::id(&cap1); transfer::public_transfer(cap1, user1); - let cap2 = trail.new_capability( + let cap2 = trail.roles_mut().new_capability_without_restrictions( &admin_cap, &string::utf8(b"RecordAdmin"), + &clock, ts::ctx(&mut scenario), ); let cap2_id = object::id(&cap2); transfer::public_transfer(cap2, user2); - ts::return_to_sender(&scenario, admin_cap); - ts::return_shared(trail); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); (cap1_id, cap2_id) }; @@ -343,19 +335,19 @@ fun test_destroy_capability() { let cap1 = ts::take_from_sender(&scenario); // Verify both capabilities are tracked before destruction - let cap_count_before = trail.issued_capabilities().size(); - assert!(trail.issued_capabilities().contains(&cap1_id), 0); - assert!(trail.issued_capabilities().contains(&cap2_id), 1); + let cap_count_before = trail.roles().issued_capabilities().size(); + assert!(trail.roles().issued_capabilities().contains(&cap1_id), 0); + assert!(trail.roles().issued_capabilities().contains(&cap2_id), 1); // Destroy the capability - trail.destroy_capability(cap1); + trail.roles_mut().destroy_capability(cap1); // Verify capability was removed from tracking - assert!(trail.issued_capabilities().size() == cap_count_before - 1, 2); - assert!(!trail.issued_capabilities().contains(&cap1_id), 3); + assert!(trail.roles().issued_capabilities().size() == cap_count_before - 1, 2); + assert!(!trail.roles().issued_capabilities().contains(&cap1_id), 3); // Verify other capability is still tracked - assert!(trail.issued_capabilities().contains(&cap2_id), 4); + assert!(trail.roles().issued_capabilities().contains(&cap2_id), 4); ts::return_shared(trail); }; @@ -373,14 +365,14 @@ fun test_destroy_capability() { let mut trail = ts::take_shared>(&scenario); let cap2 = ts::take_from_sender(&scenario); - let cap_count_before = trail.issued_capabilities().size(); + let cap_count_before = trail.roles().issued_capabilities().size(); - trail.destroy_capability(cap2); + trail.roles_mut().destroy_capability(cap2); // Verify capability was removed from tracking - assert!(trail.issued_capabilities().size() == cap_count_before - 1, 6); - assert!(!trail.issued_capabilities().contains(&cap2_id), 7); - + assert!(trail.roles().issued_capabilities().size() == cap_count_before - 1, 6); + assert!(!trail.roles().issued_capabilities().contains(&cap2_id), 7); + ts::return_shared(trail); }; @@ -390,7 +382,7 @@ fun test_destroy_capability() { let trail = ts::take_shared>(&scenario); // Only the initial admin capability should remain - assert!(trail.issued_capabilities().size() == 1, 8); + assert!(trail.roles().issued_capabilities().size() == 1, 8); ts::return_shared(trail); }; @@ -414,75 +406,57 @@ fun test_capability_lifecycle() { let mut scenario = ts::begin(admin_user); // Setup: Create audit trail - let trail_id = { - let locking_config = locking::new(locking::window_count_based(0)); - - let (admin_cap, trail_id) = setup_test_audit_trail( - &mut scenario, - locking_config, - std::option::none() - ); - - transfer::public_transfer(admin_cap, admin_user); - trail_id - }; + let _trail_id = setup_trail_with_record_admin_role(&mut scenario, admin_user); + - // Create roles + // Create an additional RoleAdmin role ts::next_tx(&mut scenario, admin_user); { - let admin_cap = ts::take_from_sender(&scenario); - let mut trail = ts::take_shared>(&scenario); + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); // Initially only admin cap should be tracked - assert!(trail.issued_capabilities().size() == 1, 0); - - trail.create_role( - &admin_cap, - string::utf8(b"RecordAdmin"), - permission::record_admin_permissions(), - ts::ctx(&mut scenario), - ); + assert!(trail.roles().issued_capabilities().size() == 1, 0); - trail.create_role( + trail.roles_mut().create_role( &admin_cap, string::utf8(b"RoleAdmin"), permission::role_admin_permissions(), + &clock, ts::ctx(&mut scenario), ); - ts::return_to_sender(&scenario, admin_cap); - ts::return_shared(trail); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); }; // Issue capabilities ts::next_tx(&mut scenario, admin_user); let (record_cap_id, role_cap_id) = { - let admin_cap = ts::take_from_sender(&scenario); - let mut trail = ts::take_shared>(&scenario); + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); - let record_cap = trail.new_capability( + let record_cap = trail.roles_mut().new_capability_without_restrictions( &admin_cap, &string::utf8(b"RecordAdmin"), + &clock, ts::ctx(&mut scenario), ); let record_cap_id = object::id(&record_cap); transfer::public_transfer(record_cap, record_admin_user); - let role_cap = trail.new_capability( + let role_cap = trail.roles_mut().new_capability_without_restrictions( &admin_cap, &string::utf8(b"RoleAdmin"), + &clock, ts::ctx(&mut scenario), ); let role_cap_id = object::id(&role_cap); transfer::public_transfer(role_cap, role_admin_user); // Verify all capabilities are tracked - assert!(trail.issued_capabilities().size() == 3, 1); // admin + record + role - assert!(trail.issued_capabilities().contains(&record_cap_id), 2); - assert!(trail.issued_capabilities().contains(&role_cap_id), 3); + assert!(trail.roles().issued_capabilities().size() == 3, 1); // admin + record + role + assert!(trail.roles().issued_capabilities().contains(&record_cap_id), 2); + assert!(trail.roles().issued_capabilities().contains(&role_cap_id), 3); - ts::return_to_sender(&scenario, admin_cap); - ts::return_shared(trail); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); (record_cap_id, role_cap_id) }; @@ -490,10 +464,8 @@ fun test_capability_lifecycle() { // Use RecordAdmin capability to add a record ts::next_tx(&mut scenario, record_admin_user); { - let mut trail = ts::take_shared>(&scenario); - let record_cap = ts::take_from_sender(&scenario); - - let mut clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); + let (record_cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); + clock.set_for_testing(test_utils::initial_time_for_testing() + 1000); let test_data = test_utils::new_test_data(1, b"Test record"); @@ -505,9 +477,7 @@ fun test_capability_lifecycle() { ts::ctx(&mut scenario), ); - iota::clock::destroy_for_testing(clock); - ts::return_to_sender(&scenario, record_cap); - ts::return_shared(trail); + cleanup_capability_trail_and_clock(&scenario, record_cap, trail, clock); }; // RecordAdmin destroys their capability @@ -516,35 +486,728 @@ fun test_capability_lifecycle() { let mut trail = ts::take_shared>(&scenario); let record_cap = ts::take_from_sender(&scenario); - trail.destroy_capability(record_cap); + trail.roles_mut().destroy_capability(record_cap); // Verify capability was removed - assert!(trail.issued_capabilities().size() == 2, 4); // admin + role - assert!(!trail.issued_capabilities().contains(&record_cap_id), 5); - + assert!(trail.roles().issued_capabilities().size() == 2, 4); // admin + role + assert!(!trail.roles().issued_capabilities().contains(&record_cap_id), 5); + ts::return_shared(trail); }; // Admin revokes RoleAdmin capability - ts::next_tx(&mut scenario, role_admin_user); + ts::next_tx(&mut scenario, admin_user); { - let admin_cap = ts::take_from_address(&scenario, admin_user); - let mut trail = ts::take_shared>(&scenario); - let role_cap = ts::take_from_sender(&scenario); + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + let role_cap = ts::take_from_address(&scenario, role_admin_user); - trail.revoke_capability( + trail.roles_mut().revoke_capability( &admin_cap, role_cap.id(), + &clock, + ts::ctx(&mut scenario), ); // Verify capability was removed - assert!(trail.issued_capabilities().size() == 1, 6); // only admin remains - assert!(!trail.issued_capabilities().contains(&role_cap_id), 7); + assert!(trail.roles().issued_capabilities().size() == 1, 6); // only admin remains + assert!(!trail.roles().issued_capabilities().contains(&role_cap_id), 7); + + ts::return_to_address(role_admin_user, role_cap); + + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + + }; + + ts::end(scenario); +} + +/// Test capability with only issued_to restriction (address-restricted capability). +/// +/// This test validates: +/// - Capability can only be used by the specified address +/// - Capability is not restricted by time +/// - Other users cannot use the capability even if they possess it +#[test, expected_failure(abort_code = audit_trail::role_map::ECapabilityIssuedToMismatch)] +fun test_capability_issued_to_only() { + let admin_user = @0xAD; + let authorized_user = @0xB0B; + let unauthorized_user = @0xCAB; + + let mut scenario = ts::begin(admin_user); + + let _trail_id = setup_trail_with_record_admin_role(&mut scenario, admin_user); + + // Issue capability restricted to authorized_user only + ts::next_tx(&mut scenario, admin_user); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + let cap = trail.roles_mut().new_capability_for_address( + &admin_cap, + &string::utf8(b"RecordAdmin"), + authorized_user, + std::option::none(), // no time restriction + &clock, + ts::ctx(&mut scenario), + ); + + // Verify capability properties + assert!(cap.issued_to() == std::option::some(authorized_user), 0); + assert!(cap.valid_from().is_none(), 1); + assert!(cap.valid_until().is_none(), 2); + + transfer::public_transfer(cap, authorized_user); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + }; + + // Authorized user can use the capability + ts::next_tx(&mut scenario, authorized_user); + { + let (record_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + + let test_data = test_utils::new_test_data(1, b"Authorized record"); + trail.add_record( + &record_cap, + test_data, + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + // Transfer the capability to he unauthorized_user to prepare the next test + transfer::public_transfer(record_cap, unauthorized_user); - ts::return_to_address(admin_user, admin_cap); - ts::return_to_sender(&scenario, role_cap); + // Cleanup + iota::clock::destroy_for_testing(clock); ts::return_shared(trail); }; + // Unauthorized user cannot use the capability + ts::next_tx(&mut scenario, unauthorized_user); + { + let (record_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + // This should fail as unauthorized_user has the wrong address + let test_data = test_utils::new_test_data(1, b"Unauthorized record"); + trail.add_record( + &record_cap, + test_data, + std::option::none(), + &clock, + ts::ctx(&mut scenario) + ); + + cleanup_capability_trail_and_clock(&scenario, record_cap, trail, clock); + }; + ts::end(scenario); -} \ No newline at end of file +} + +/// Test capability with only valid_from restriction (time-restricted from a point). +/// +/// This test validates: +/// - Capability can be used after valid_from timestamp +/// - Capability is not restricted by address or end time +/// - Capability cannot be used before valid_from timestamp +#[test, expected_failure(abort_code = audit_trail::role_map::ECapabilityTimeConstraintsNotMet)] +fun test_capability_valid_from_only() { + let admin_user = @0xAD; + let user = @0xB0B; + + let mut scenario = ts::begin(admin_user); + + let valid_from_time = test_utils::initial_time_for_testing() + 5000; + + // Setup + let _trail_id = setup_trail_with_record_admin_role(&mut scenario, admin_user); + + // Issue capability with valid_from restriction only + ts::next_tx(&mut scenario, admin_user); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + let cap = trail.roles_mut().new_capability( + &admin_cap, + &string::utf8(b"RecordAdmin"), + std::option::none(), // no address restriction + std::option::some(valid_from_time), + std::option::none(), // no valid_until + &clock, + ts::ctx(&mut scenario) + ); + + // Verify capability properties + assert!(cap.issued_to().is_none(), 0); + assert!(cap.valid_from() == std::option::some(valid_from_time), 1); + assert!(cap.valid_until().is_none(), 2); + + transfer::public_transfer(cap, user); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + }; + + // Use the capability after valid_from + ts::next_tx(&mut scenario, user); + { + let (cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); + clock.set_for_testing(test_utils::initial_time_for_testing() + 6000); + + let test_data = test_utils::new_test_data(1, b"Test record after valid_from"); + trail.add_record( + &cap, + test_data, + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + cleanup_capability_trail_and_clock(&scenario, cap, trail, clock); + }; + + // Try to use the capability before valid_from + ts::next_tx(&mut scenario, user); + { + let (cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); + clock.set_for_testing(test_utils::initial_time_for_testing() + 1000); + + // This should fail as the capability is not valid yet + let test_data = test_utils::new_test_data(1, b"Test record before valid_from"); + trail.add_record( + &cap, + test_data, + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + cleanup_capability_trail_and_clock(&scenario, cap, trail, clock); + }; + + ts::end(scenario); +} + +/// Test capability with only valid_until restriction (time-restricted until a point). +/// +/// This test validates: +/// - Capability can be used before valid_until timestamp +/// - Capability is not restricted by address or start time +/// - Capability cannot be used after valid_until timestamp +#[test, expected_failure(abort_code = audit_trail::role_map::ECapabilityTimeConstraintsNotMet)] +fun test_capability_valid_until_only() { + let admin_user = @0xAD; + let user = @0xB0B; + + let mut scenario = ts::begin(admin_user); + + let valid_until_time_secs= test_utils::initial_time_for_testing() / 1000 + 10; + + // Setup + let _trail_id = setup_trail_with_record_admin_role(&mut scenario, admin_user); + + // Issue capability with valid_until restriction + ts::next_tx(&mut scenario, admin_user); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + let cap = trail.roles_mut().new_capability_valid_until( + &admin_cap, + &string::utf8(b"RecordAdmin"), + valid_until_time_secs, + &clock, + ts::ctx(&mut scenario), + ); + + // Verify capability properties + assert!(cap.issued_to().is_none(), 0); + assert!(cap.valid_from().is_none(), 1); + assert!(cap.valid_until() == std::option::some(valid_until_time_secs), 2); + + transfer::public_transfer(cap, user); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + }; + + // Use the capability before valid_until + ts::next_tx(&mut scenario, user); + { + let (cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); + clock.set_for_testing(valid_until_time_secs* 1000 - 1000); + + let test_data = test_utils::new_test_data(1, b"Test record before valid_until"); + trail.add_record( + &cap, + test_data, + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + cleanup_capability_trail_and_clock(&scenario, cap, trail, clock); + }; + + // Try to use the capability after valid_until + ts::next_tx(&mut scenario, user); + { + let (cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); + clock.set_for_testing(valid_until_time_secs* 1000 + 1000); + + // This should fail as the capability has expired + let test_data = test_utils::new_test_data(1, b"Test record after valid_until"); + trail.add_record( + &cap, + test_data, + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + cleanup_capability_trail_and_clock(&scenario, cap, trail, clock); + }; + + ts::end(scenario); +} + + +/// Test capability with valid_from and valid_until restrictions (time window). +/// +/// This test validates: +/// - Capability can be used between valid_from and valid_until +/// - Capability is not restricted by address +#[test] +fun test_capability_time_window() { + let admin_user = @0xAD; + let user = @0xB0B; + + let mut scenario = ts::begin(admin_user); + + let valid_from_time = test_utils::initial_time_for_testing() + 5000; + let valid_until_time = test_utils::initial_time_for_testing() + 10000; + + // Setup + let _trail_id = setup_trail_with_record_admin_capability_and_time_window_restriction( + &mut scenario, + admin_user, + user, + valid_from_time / 1000, + valid_until_time / 1000, + ); + + // Use the capability within the valid time window + ts::next_tx(&mut scenario, user); + { + let (cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); + clock.set_for_testing(valid_from_time + 2500); + + let test_data = test_utils::new_test_data(1, b"Test record within time window"); + trail.add_record( + &cap, + test_data, + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + cleanup_capability_trail_and_clock(&scenario, cap, trail, clock); + }; + + ts::end(scenario); +} + +/// Test capability with valid_from and valid_until restrictions (time window). +/// +/// This test validates: +/// - Capability cannot be used before valid_from +#[test, expected_failure(abort_code = audit_trail::role_map::ECapabilityTimeConstraintsNotMet)] +fun test_capability_time_window_before_valid_from() { + let admin_user = @0xAD; + let user = @0xB0B; + + let mut scenario = ts::begin(admin_user); + + let valid_from_time_secs= test_utils::initial_time_for_testing() / 1000 + 5; + let valid_until_time_secs= test_utils::initial_time_for_testing() / 1000 + 10; + + // Setup + let _trail_id = setup_trail_with_record_admin_capability_and_time_window_restriction( + &mut scenario, + admin_user, + user, + valid_from_time_secs, + valid_until_time_secs, + ); + + // Use the capability before valid_from + ts::next_tx(&mut scenario, user); + { + let (cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); + clock.set_for_testing(valid_from_time_secs* 1000 - 1000); + + let test_data = test_utils::new_test_data(1, b"Test record before valid_from"); + trail.add_record( + &cap, + test_data, + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + cleanup_capability_trail_and_clock(&scenario, cap, trail, clock); + }; + + ts::end(scenario); +} + +/// Test capability with valid_from and valid_until restrictions (time window). +/// +/// This test validates: +/// - Capability cannot be used after valid_until +#[test, expected_failure(abort_code = audit_trail::role_map::ECapabilityTimeConstraintsNotMet)] +fun test_capability_time_window_after_valid_until() { + let admin_user = @0xAD; + let user = @0xB0B; + + let mut scenario = ts::begin(admin_user); + + let valid_from_time_secs= test_utils::initial_time_for_testing() / 1000 + 5; + let valid_until_time_secs= test_utils::initial_time_for_testing() / 1000 + 10; + + // Setup + let _trail_id = setup_trail_with_record_admin_capability_and_time_window_restriction( + &mut scenario, + admin_user, + user, + valid_from_time_secs, + valid_until_time_secs, + ); + + // Use the capability after valid_until + ts::next_tx(&mut scenario, user); + { + let (cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); + clock.set_for_testing(valid_until_time_secs* 1000 + 1000); + + let test_data = test_utils::new_test_data(1, b"Test record after valid_until"); + trail.add_record( + &cap, + test_data, + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + cleanup_capability_trail_and_clock(&scenario, cap, trail, clock); + }; + + ts::end(scenario); +} + +/// Test Capability::is_valid_for_timestamp function. +/// +/// This test validates: +/// - Returns true when timestamp is within valid range +/// - Returns false when timestamp is before valid_from +/// - Returns false when timestamp is after valid_until +/// - Returns true when no time restrictions exist +#[test] +fun test_is_valid_for_timestamp() { + let admin_user = @0xAD; + let user = @0xB0B; + + let mut scenario = ts::begin(admin_user); + + let base_time = test_utils::initial_time_for_testing(); + let valid_from_time = base_time + 5000; + let valid_until_time = base_time + 10000; + + // Setup + let _trail_id = setup_trail_with_record_admin_role(&mut scenario, admin_user); + + // Test with time-restricted capability + ts::next_tx(&mut scenario, admin_user); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + let cap = trail.roles_mut().new_capability( + &admin_cap, + &string::utf8(b"RecordAdmin"), + std::option::none(), + std::option::some(valid_from_time), + std::option::some(valid_until_time), + &clock, + ts::ctx(&mut scenario), + ); + + // Before valid_from + assert!(!cap.is_valid_for_timestamp(valid_from_time - 1), 0); + + // At valid_from (inclusive) + assert!(cap.is_valid_for_timestamp(valid_from_time), 1); + + // During validity period + assert!(cap.is_valid_for_timestamp(valid_from_time + 2500), 2); + + // Before valid_until (exclusive) + assert!(cap.is_valid_for_timestamp(valid_until_time - 1), 3); + + // At valid_until (exclusive) + assert!(!cap.is_valid_for_timestamp(valid_until_time), 4); + + // After valid_until + assert!(!cap.is_valid_for_timestamp(valid_until_time + 1), 5); + + transfer::public_transfer(cap, user); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + }; + + // Test with unrestricted capability + ts::next_tx(&mut scenario, admin_user); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + let unrestricted_cap = trail.roles_mut().new_capability_without_restrictions( + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); + + // Should be valid at any timestamp + assert!(unrestricted_cap.is_valid_for_timestamp(0), 6); + assert!(unrestricted_cap.is_valid_for_timestamp(base_time), 7); + assert!(unrestricted_cap.is_valid_for_timestamp(valid_until_time + 99999), 8); + + transfer::public_transfer(unrestricted_cap, user); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + }; + + ts::end(scenario); +} + +/// Test Capability::is_currently_valid function. +/// +/// This test validates: +/// - Returns true when current time is within valid range +/// - Returns false when current time is outside valid range +/// - Works correctly with Clock object +#[test] +fun test_is_currently_valid() { + let admin_user = @0xAD; + let user = @0xB0B; + + let mut scenario = ts::begin(admin_user); + + let base_time = test_utils::initial_time_for_testing(); + let valid_from_time = base_time + 5000; + let valid_until_time = base_time + 10000; + + // Setup + let _trail_id = setup_trail_with_record_admin_role(&mut scenario, admin_user); + + // Issue time-restricted capability + ts::next_tx(&mut scenario, admin_user); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + let cap = trail.roles_mut().new_capability( + &admin_cap, + &string::utf8(b"RecordAdmin"), + std::option::none(), + std::option::some(valid_from_time / 1000), + std::option::some(valid_until_time / 1000), + &clock, + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(cap, user); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + }; + + // Test before valid_from + ts::next_tx(&mut scenario, user); + { + let cap = ts::take_from_sender(&scenario); + let mut clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); + clock.set_for_testing(valid_from_time - 1000); + + assert!(!cap.is_currently_valid(&clock), 0); + + iota::clock::destroy_for_testing(clock); + ts::return_to_sender(&scenario, cap); + }; + + // Test during valid period + ts::next_tx(&mut scenario, user); + { + let cap = ts::take_from_sender(&scenario); + let mut clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); + clock.set_for_testing(valid_from_time + 2500); + + assert!(cap.is_currently_valid(&clock), 1); + + iota::clock::destroy_for_testing(clock); + ts::return_to_sender(&scenario, cap); + }; + + // Test after valid_until + ts::next_tx(&mut scenario, user); + { + let cap = ts::take_from_sender(&scenario); + let mut clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); + clock.set_for_testing(valid_until_time + 1000); + + assert!(!cap.is_currently_valid(&clock), 2); + + iota::clock::destroy_for_testing(clock); + ts::return_to_sender(&scenario, cap); + }; + + ts::end(scenario); +} + +/// Test Capability::new_capability_without_restrictions function. +/// +/// This test validates: +/// - Creates capability with no restrictions +/// - issued_to, valid_from, and valid_until are all None +/// - Capability can be used by anyone at any time +#[test] +fun test_new_capability_without_restrictions() { + let admin_user = @0xAD; + let any_user = @0xB0B; + + let mut scenario = ts::begin(admin_user); + + // Setup + let trail_id = setup_trail_with_record_admin_role(&mut scenario, admin_user); + + // Create unrestricted capability + ts::next_tx(&mut scenario, admin_user); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + let cap = trail.roles_mut().new_capability_without_restrictions( + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); + + // Verify no restrictions + assert!(cap.issued_to().is_none(), 0); + assert!(cap.valid_from().is_none(), 1); + assert!(cap.valid_until().is_none(), 2); + assert!(cap.role() == string::utf8(b"RecordAdmin"), 3); + assert!(cap.security_vault_id() == trail_id, 4); + + transfer::public_transfer(cap, any_user); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + }; + + // Verify any user can use it at any time + ts::next_tx(&mut scenario, any_user); + { + let (cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); + clock.set_for_testing(999999999); + + let test_data = test_utils::new_test_data(1, b"Test"); + trail.add_record( + &cap, + test_data, + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + cleanup_capability_trail_and_clock(&scenario, cap, trail, clock); + }; + + ts::end(scenario); +} + +/// Test Capability::new_capability_valid_until function. +/// +/// This test validates: +/// - Creates capability with only valid_until restriction +/// - issued_to and valid_from are None +/// - Capability expires at the specified timestamp +#[test] +fun test_new_capability_valid_until() { + let admin_user = @0xAD; + let user = @0xB0B; + + let mut scenario = ts::begin(admin_user); + + let valid_until_time = test_utils::initial_time_for_testing() + 10000; + + // Setup + let trail_id = setup_trail_with_record_admin_role(&mut scenario, admin_user); + + // Create capability with valid_until + ts::next_tx(&mut scenario, admin_user); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + let cap = trail.roles_mut().new_capability_valid_until( + &admin_cap, + &string::utf8(b"RecordAdmin"), + valid_until_time, + &clock, + ts::ctx(&mut scenario), + ); + + // Verify restrictions + assert!(cap.issued_to().is_none(), 0); + assert!(cap.valid_from().is_none(), 1); + assert!(cap.valid_until() == std::option::some(valid_until_time), 2); + assert!(cap.role() == string::utf8(b"RecordAdmin"), 3); + assert!(cap.security_vault_id() == trail_id, 4); + + transfer::public_transfer(cap, user); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + }; + + ts::end(scenario); +} + +/// Test Capability::new_capability_for_address with None for valid_until. +/// +/// This test validates: +/// - Creates capability restricted to specific address +/// - valid_until is None (no expiration) +/// - valid_from is None +#[test] +fun test_new_capability_for_address_no_expiration() { + let admin_user = @0xAD; + let authorized_user = @0xB0B; + + let mut scenario = ts::begin(admin_user); + + // Setup + let trail_id = setup_trail_with_record_admin_role(&mut scenario, admin_user); + + // Create capability for address without expiration + ts::next_tx(&mut scenario, admin_user); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + let cap = trail.roles_mut().new_capability_for_address( + &admin_cap, + &string::utf8(b"RecordAdmin"), + authorized_user, + std::option::none(), // no expiration + &clock, + ts::ctx(&mut scenario), + ); + + // Verify restrictions + assert!(cap.issued_to() == std::option::some(authorized_user), 0); + assert!(cap.valid_from().is_none(), 1); + assert!(cap.valid_until().is_none(), 2); + assert!(cap.role() == string::utf8(b"RecordAdmin"), 3); + assert!(cap.security_vault_id() == trail_id, 4); + + transfer::public_transfer(cap, authorized_user); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + }; + + ts::end(scenario); +} diff --git a/audit-trail-move/tests/create_audit_trail_tests.move b/audit-trail-move/tests/create_audit_trail_tests.move index 6de3b79d..358a6f38 100644 --- a/audit-trail-move/tests/create_audit_trail_tests.move +++ b/audit-trail-move/tests/create_audit_trail_tests.move @@ -4,8 +4,7 @@ module audit_trail::create_audit_trail_tests; use audit_trail::main::{Self, AuditTrail, initial_admin_role_name}; use audit_trail::locking::{Self}; -use audit_trail::capability::{Capability}; -use audit_trail::test_utils::{setup_test_audit_trail, new_test_data, initial_time_for_testing, TestData}; +use audit_trail::test_utils::{setup_test_audit_trail, new_test_data, initial_time_for_testing, TestData, fetch_capability_trail_and_clock, cleanup_capability_trail_and_clock}; use iota::test_scenario::{Self as ts}; use iota::clock::{Self}; use std::string::{Self}; @@ -30,7 +29,7 @@ fun test_create_without_initial_record() { // Verify capability was created assert!(admin_cap.role() == initial_admin_role_name(), 0); - assert!(admin_cap.trail_id() == trail_id, 1); + assert!(admin_cap.security_vault_id() == trail_id, 1); // Clean up admin_cap.destroy_for_testing(); @@ -72,7 +71,7 @@ fun test_create_with_initial_record() { // Verify capability assert!(admin_cap.role() == initial_admin_role_name(), 0); - assert!(admin_cap.trail_id() == trail_id, 1); + assert!(admin_cap.security_vault_id() == trail_id, 1); // Clean up admin_cap.destroy_for_testing(); @@ -254,7 +253,7 @@ fun test_create_metadata_admin_role() { // Verify admin capability was created assert!(admin_cap.role() == initial_admin_role_name(), 0); - assert!(admin_cap.trail_id() == trail_id, 1); + assert!(admin_cap.security_vault_id() == trail_id, 1); // Transfer the admin capability to the user transfer::public_transfer(admin_cap, user); @@ -263,22 +262,21 @@ fun test_create_metadata_admin_role() { // User receives the capability and creates the MetadataAdmin role ts::next_tx(&mut scenario, user); { - let admin_cap = ts::take_from_sender(&scenario); - let mut trail = ts::take_shared>(&scenario); - + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); // Create the MetadataAdmin role using the admin capability let metadata_admin_role_name = string::utf8(b"MetadataAdmin"); let metadata_admin_perms = audit_trail::permission::metadata_admin_permissions(); - trail.create_role( + trail.roles_mut().create_role( &admin_cap, metadata_admin_role_name, metadata_admin_perms, + &clock, ts::ctx(&mut scenario), ); // Verify the role was created by fetching its permissions - let role_perms = trail.get_role_permissions(&string::utf8(b"MetadataAdmin")); + let role_perms = trail.roles().get_role_permissions(&string::utf8(b"MetadataAdmin")); // Verify the role has the correct permissions assert!(audit_trail::permission::has_permission(role_perms, &audit_trail::permission::update_metadata()), 2); @@ -286,8 +284,7 @@ fun test_create_metadata_admin_role() { assert!(iota::vec_set::size(role_perms) == 2, 4); // Clean up - ts::return_to_sender(&scenario, admin_cap); - ts::return_shared(trail); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); }; ts::end(scenario); diff --git a/audit-trail-move/tests/role_tests.move b/audit-trail-move/tests/role_tests.move index e0fc15cc..695d5ca3 100644 --- a/audit-trail-move/tests/role_tests.move +++ b/audit-trail-move/tests/role_tests.move @@ -3,12 +3,10 @@ module audit_trail::role_tests; use audit_trail::permission::{Self}; use audit_trail::locking::{Self}; -use audit_trail::main::{Self, AuditTrail, initial_admin_role_name}; -use audit_trail::test_utils::{Self, TestData, setup_test_audit_trail}; +use audit_trail::main::{initial_admin_role_name}; +use audit_trail::test_utils::{Self, setup_test_audit_trail, fetch_capability_trail_and_clock, cleanup_capability_trail_and_clock}; use iota::test_scenario::{Self as ts}; -use iota::clock::{Self}; use std::string::{Self}; -use audit_trail::capability::Capability; /// Test comprehensive role-based access control delegation workflow. /// @@ -46,7 +44,7 @@ fun test_role_based_permission_delegation() { // Verify admin capability was created with correct role and trail reference assert!(admin_cap.role() == initial_admin_role_name(), 0); - assert!(admin_cap.trail_id() == trail_id, 1); + assert!(admin_cap.security_vault_id() == trail_id, 1); // Transfer the admin capability to the user transfer::public_transfer(admin_cap, admin_user); @@ -57,129 +55,127 @@ fun test_role_based_permission_delegation() { // Step 2: Admin creates RoleAdmin and CapAdmin roles ts::next_tx(&mut scenario, admin_user); { - let admin_cap = ts::take_from_sender(&scenario); - let mut trail = ts::take_shared>(&scenario); + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); // Verify initial state - should only have the initial admin role assert!(trail.roles().size() == 1, 2); // Create RoleAdmin role let role_admin_perms = permission::role_admin_permissions(); - trail.create_role( + trail.roles_mut().create_role( &admin_cap, string::utf8(b"RoleAdmin"), role_admin_perms, + &clock, ts::ctx(&mut scenario), ); // Create CapAdmin role let cap_admin_perms = permission::cap_admin_permissions(); - trail.create_role( + trail.roles_mut().create_role( &admin_cap, string::utf8(b"CapAdmin"), cap_admin_perms, + &clock, ts::ctx(&mut scenario), ); // Verify both roles were created assert!(trail.roles().size() == 3, 3); // Initial admin + RoleAdmin + CapAdmin - assert!(trail.has_role(&string::utf8(b"RoleAdmin")), 4); - assert!(trail.has_role(&string::utf8(b"CapAdmin")), 5); + assert!(trail.roles().has_role(&string::utf8(b"RoleAdmin")), 4); + assert!(trail.roles().has_role(&string::utf8(b"CapAdmin")), 5); - ts::return_to_sender(&scenario, admin_cap); - ts::return_shared(trail); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); }; // Step 3: Admin creates capability for RoleAdmin and CapAdmin and transfers to the respective users ts::next_tx(&mut scenario, admin_user); { - let admin_cap = ts::take_from_sender(&scenario); - let mut trail = ts::take_shared>(&scenario); - - let role_admin_cap = trail.new_capability( + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + let role_admin_cap = trail.roles_mut().new_capability_without_restrictions( &admin_cap, &string::utf8(b"RoleAdmin"), + &clock, ts::ctx(&mut scenario), ); // Verify the capability was created with correct role and trail ID assert!(role_admin_cap.role() == string::utf8(b"RoleAdmin"), 6); - assert!(role_admin_cap.trail_id() == trail_id, 7); + assert!(role_admin_cap.security_vault_id() == trail_id, 7); iota::transfer::public_transfer(role_admin_cap, role_admin_user); - let cap_admin_cap = trail.new_capability( + let cap_admin_cap = trail.roles_mut().new_capability_without_restrictions( &admin_cap, &string::utf8(b"CapAdmin"), + &clock, ts::ctx(&mut scenario), ); // Verify the capability was created with correct role and trail ID assert!(cap_admin_cap.role() == string::utf8(b"CapAdmin"), 8); - assert!(cap_admin_cap.trail_id() == trail_id, 9); + assert!(cap_admin_cap.security_vault_id() == trail_id, 9); iota::transfer::public_transfer(cap_admin_cap, cap_admin_user); - ts::return_to_sender(&scenario, admin_cap); - ts::return_shared(trail); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); }; // Step 5: RoleAdmin creates RecordAdmin role (demonstrating delegated role management) ts::next_tx(&mut scenario, role_admin_user); { - let mut trail = ts::take_shared>(&scenario); - let role_admin_cap = ts::take_from_sender(&scenario); + let (role_admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); // Verify RoleAdmin has the correct role assert!(role_admin_cap.role() == string::utf8(b"RoleAdmin"), 10); let record_admin_perms = permission::record_admin_permissions(); - trail.create_role( + trail.roles_mut().create_role( &role_admin_cap, string::utf8(b"RecordAdmin"), record_admin_perms, + &clock, ts::ctx(&mut scenario), ); // Verify RecordAdmin role was created successfully assert!(trail.roles().size() == 4, 11); // Initial admin + RoleAdmin + CapAdmin + RecordAdmin - assert!(trail.has_role(&string::utf8(b"RecordAdmin")), 12); + assert!(trail.roles().has_role(&string::utf8(b"RecordAdmin")), 12); - ts::return_to_sender(&scenario, role_admin_cap); - ts::return_shared(trail); + cleanup_capability_trail_and_clock(&scenario, role_admin_cap, trail, clock); }; // Step 6: CapAdmin creates capability for RecordAdmin and transfers to record_admin_user ts::next_tx(&mut scenario, cap_admin_user); { - let mut trail = ts::take_shared>(&scenario); - let cap_admin_cap = ts::take_from_sender(&scenario); + let (cap_admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); // Verify CapAdmin has the correct role assert!(cap_admin_cap.role() == string::utf8(b"CapAdmin"), 13); - let record_admin_cap = trail.new_capability( + let record_admin_cap = trail.roles_mut().new_capability_without_restrictions( &cap_admin_cap, &string::utf8(b"RecordAdmin"), + &clock, ts::ctx(&mut scenario), ); // Verify the capability was created with correct role and trail ID assert!(record_admin_cap.role() == string::utf8(b"RecordAdmin"), 14); - assert!(record_admin_cap.trail_id() == trail_id, 15); + assert!(record_admin_cap.security_vault_id() == trail_id, 15); iota::transfer::public_transfer(record_admin_cap, record_admin_user); - ts::return_to_sender(&scenario, cap_admin_cap); - ts::return_shared(trail); + cleanup_capability_trail_and_clock(&scenario, cap_admin_cap, trail, clock); }; // Step 7: RecordAdmin adds a new record to the audit trail (demonstrating delegated record management) ts::next_tx(&mut scenario, record_admin_user); { - let mut trail = ts::take_shared>(&scenario); - let record_admin_cap = ts::take_from_sender(&scenario); + let (record_admin_cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); + clock.set_for_testing(test_utils::initial_time_for_testing() + 1000); // Verify RecordAdmin has the correct role assert!(record_admin_cap.role() == string::utf8(b"RecordAdmin"), 16); @@ -187,8 +183,6 @@ fun test_role_based_permission_delegation() { // Verify initial record count let initial_record_count = trail.records().length(); - let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); - clock.set_for_testing(test_utils::initial_time_for_testing() + 1000); let test_data = test_utils::new_test_data(42, b"Test record added by RecordAdmin"); @@ -203,9 +197,7 @@ fun test_role_based_permission_delegation() { // Verify the record was added successfully assert!(trail.records().length() == initial_record_count + 1, 17); - clock::destroy_for_testing(clock); - ts::return_to_sender(&scenario, record_admin_cap); - ts::return_shared(trail); + cleanup_capability_trail_and_clock(&scenario, record_admin_cap, trail, clock); }; // Cleanup diff --git a/audit-trail-move/tests/test_utils.move b/audit-trail-move/tests/test_utils.move index 640b2198..c9bccc61 100644 --- a/audit-trail-move/tests/test_utils.move +++ b/audit-trail-move/tests/test_utils.move @@ -2,12 +2,15 @@ #[test_only] module audit_trail::test_utils; +use std::string::{Self}; + +use iota::clock::{Self, Clock}; + +use iota::test_scenario::{Self as ts, Scenario}; + use audit_trail::locking::{Self}; use audit_trail::capability::{Capability}; -use iota::clock::{Self}; -use iota::test_scenario::{Self as ts, Scenario}; -use audit_trail::main::{Self}; -use std::string::{Self}; +use audit_trail::main::{Self, AuditTrail}; const INITIAL_TIME_FOR_TESTING: u64 = 1234; @@ -54,4 +57,22 @@ public(package) fun setup_test_audit_trail(scenario: &mut Scenario, locking_conf }; (admin_cap, trail_id) -} \ No newline at end of file +} + +public(package) fun fetch_capability_trail_and_clock(scenario: &mut Scenario): (Capability, AuditTrail, Clock) { + let admin_cap = ts::take_from_sender(scenario); + let trail = ts::take_shared>(scenario); + let clock = iota::clock::create_for_testing(ts::ctx(scenario)); + (admin_cap, trail, clock) +} + +public(package) fun cleanup_capability_trail_and_clock( + scenario: &Scenario, + cap: Capability, + trail: AuditTrail, + clock: Clock, +) { + iota::clock::destroy_for_testing(clock); + ts::return_to_sender(scenario, cap); + ts::return_shared(trail); +} diff --git a/notarization-move/sources/timelock.move b/notarization-move/sources/timelock.move index e287228d..c6eb46f5 100644 --- a/notarization-move/sources/timelock.move +++ b/notarization-move/sources/timelock.move @@ -23,7 +23,7 @@ const ETimelockNotExpired: u64 = 1; /// Represents different types of time-based locks that can be applied to /// notarizations. public enum TimeLock has store { - /// A lock that unlocks at a specific Unix timestamp (seconds since epoch) + /// A lock that unlocks at a specific Unix timestamp (seconds since Unix epoch) UnlockAt(u32), /// A permanent lock that never unlocks until the notarization object is destroyed (can't be used for `delete_lock`) UntilDestroyed, From a8d084ef84c60f41dcf96e138e4cd1ccd2793e20 Mon Sep 17 00:00:00 2001 From: chrisgitiota Date: Mon, 5 Jan 2026 15:15:58 +0100 Subject: [PATCH 024/189] chore: Fixed format issues --- audit-trail-move/sources/audit_trail.move | 90 ++-- audit-trail-move/sources/capability.move | 13 +- audit-trail-move/sources/role_map.move | 181 +++---- audit-trail-move/tests/capability_tests.move | 472 ++++++++++-------- .../tests/create_audit_trail_tests.move | 46 +- audit-trail-move/tests/role_tests.move | 157 +++--- audit-trail-move/tests/test_utils.move | 33 +- 7 files changed, 545 insertions(+), 447 deletions(-) diff --git a/audit-trail-move/sources/audit_trail.move b/audit-trail-move/sources/audit_trail.move index efa268ac..9c0c8711 100644 --- a/audit-trail-move/sources/audit_trail.move +++ b/audit-trail-move/sources/audit_trail.move @@ -9,21 +9,22 @@ /// Records are addressed by trail_id + sequence_number module audit_trail::main; -use audit_trail::capability::Capability; -use audit_trail::locking::{Self, LockingConfig, LockingWindow, set_delete_record_lock}; -use audit_trail::permission::{Self, Permission}; -use audit_trail::role_map::{Self, RoleMap}; -use audit_trail::record::{Self, Record}; -use iota::clock::{Self, Clock}; -use iota::event; -use iota::linked_table::{Self, LinkedTable}; +use audit_trail::{ + capability::Capability, + locking::{Self, LockingConfig, LockingWindow, set_delete_record_lock}, + permission::{Self, Permission}, + record::{Self, Record}, + role_map::{Self, RoleMap} +}; +use iota::{clock::{Self, Clock}, event, linked_table::{Self, LinkedTable}}; use std::string::String; // ===== Errors ===== #[error] const ERecordNotFound: vector = b"Record not found at the given sequence number"; #[error] -const EPermissionDenied: vector = b"The role associated with the provided capability does not have the required permission"; +const EPermissionDenied: vector = + b"The role associated with the provided capability does not have the required permission"; // ===== Constants ===== const INITIAL_ADMIN_ROLE_NAME: vector = b"Admin"; @@ -81,7 +82,6 @@ public struct RecordAdded has copy, drop { // TODO: Add event for Record deletion and (if part of MVP) correction - // ===== Constructors ===== /// Create immutable trail metadata @@ -169,7 +169,7 @@ public fun create( permission::admin_permissions(), role_admin_permissions, capability_admin_permissions, - ctx + ctx, ); let trail = AuditTrail { @@ -213,13 +213,16 @@ public fun trail_add_record( clock: &Clock, ctx: &mut TxContext, ) { - assert!(trail.roles.is_capability_valid( - cap, - &permission::add_record(), - clock, - ctx - ), - EPermissionDenied + assert!( + trail + .roles + .is_capability_valid( + cap, + &permission::add_record(), + clock, + ctx, + ), + EPermissionDenied, ); let caller = ctx.sender(); @@ -276,13 +279,16 @@ public fun trail_update_locking_config( clock: &Clock, ctx: &TxContext, ) { - assert!(trail.roles.is_capability_valid( - cap, - &permission::update_locking_config(), - clock, - ctx - ), - EPermissionDenied + assert!( + trail + .roles + .is_capability_valid( + cap, + &permission::update_locking_config(), + clock, + ctx, + ), + EPermissionDenied, ); trail.locking_config = new_config; } @@ -295,13 +301,16 @@ public fun trail_update_locking_config_for_delete_record( clock: &Clock, ctx: &TxContext, ) { - assert!(trail.roles.is_capability_valid( - cap, - &permission::update_locking_config_for_delete_record(), - clock, - ctx - ), - EPermissionDenied + assert!( + trail + .roles + .is_capability_valid( + cap, + &permission::update_locking_config_for_delete_record(), + clock, + ctx, + ), + EPermissionDenied, ); set_delete_record_lock(&mut trail.locking_config, new_delete_record_lock); } @@ -314,13 +323,16 @@ public fun trail_update_metadata( clock: &Clock, ctx: &TxContext, ) { - assert!(trail.roles.is_capability_valid( - cap, - &permission::update_metadata(), - clock, - ctx - ), - EPermissionDenied + assert!( + trail + .roles + .is_capability_valid( + cap, + &permission::update_metadata(), + clock, + ctx, + ), + EPermissionDenied, ); trail.updatable_metadata = new_metadata; } diff --git a/audit-trail-move/sources/capability.move b/audit-trail-move/sources/capability.move index f05cbd1a..70ac5794 100644 --- a/audit-trail-move/sources/capability.move +++ b/audit-trail-move/sources/capability.move @@ -4,13 +4,14 @@ /// Role-based access control capabilities for audit trails module audit_trail::capability; -use std::string::String; use iota::clock::{Self, Clock}; +use std::string::String; // ===== Errors ===== #[error] -const EValidityPeriodInconsistent: vector = b"Validity period is inconsistent: valid_from must be before valid_until"; +const EValidityPeriodInconsistent: vector = + b"Validity period is inconsistent: valid_from must be before valid_until"; // ===== Core Structures ===== @@ -161,23 +162,23 @@ public fun cap_valid_until(cap: &Capability): &Option { } // Check if the capability is currently valid for `clock::timestamp_ms(clock)` -public fun cap_is_currently_valid(cap: &Capability, clock: &Clock,): bool { +public fun cap_is_currently_valid(cap: &Capability, clock: &Clock): bool { let current_ts = clock::timestamp_ms(clock) / 1000; // convert to seconds cap.is_valid_for_timestamp(current_ts) } // Check if the capability is valid for a specific timestamp (in seconds since Unix epoch) -public fun cap_is_valid_for_timestamp(cap: &Capability, timestamp_secs: u64,): bool { +public fun cap_is_valid_for_timestamp(cap: &Capability, timestamp_secs: u64): bool { let valid_from_ok = if (cap.valid_from.is_some()) { let from = cap.valid_from.borrow(); timestamp_secs >= *from - } else { + } else { true }; let valid_until_ok = if (cap.valid_until.is_some()) { let until = cap.valid_until.borrow(); timestamp_secs < *until - } else { + } else { true }; valid_from_ok && valid_until_ok diff --git a/audit-trail-move/sources/role_map.move b/audit-trail-move/sources/role_map.move index 1e47f96a..fa05a568 100644 --- a/audit-trail-move/sources/role_map.move +++ b/audit-trail-move/sources/role_map.move @@ -6,7 +6,7 @@ /// Provides the following functionalities: /// - Define an initial role with a custom set of permissions (i.e. an Admin role). /// - Use custom permission types defined by the integrating module using the generic parameter `P`. -/// - Create, delete, and update roles and their permissions +/// - Create, delete, and update roles and their permissions /// - Issue, revoke, and destroy `audit_trail::capability`s associated with a specific role. /// - Validate `audit_trail::capability`s against the defined roles to facilitate proper access control by other modules /// (function `RoleMap.is_capability_valid()`) @@ -14,33 +14,36 @@ /// /// Examples: /// - audit_trail::main module uses `RoleMap` to manage access to the audit trail records and their operations. - + module audit_trail::role_map; -use std::string::String; -use iota::vec_map::{Self, VecMap}; -use iota::vec_set::{Self, VecSet}; use audit_trail::capability::{Self, Capability}; -use iota::clock::Clock; -use iota::event; +use iota::{clock::Clock, event, vec_map::{Self, VecMap}, vec_set::{Self, VecSet}}; +use std::string::String; // =============== Errors ========================================================== #[error] -const EPermissionDenied: vector = b"The role associated with the provided capability does not have the required permission"; +const EPermissionDenied: vector = + b"The role associated with the provided capability does not have the required permission"; #[error] -const ERoleDoesNotExist: vector = b"The specified role, directly specified or specified by a capability, does not exist in the `RoleMap` mapping"; +const ERoleDoesNotExist: vector = + b"The specified role, directly specified or specified by a capability, does not exist in the `RoleMap` mapping"; #[error] -const ECapabilityHasBeenRevoked: vector = b"The provided capability has been revoked and is no longer valid"; +const ECapabilityHasBeenRevoked: vector = + b"The provided capability has been revoked and is no longer valid"; #[error] -const ECapabilitySecurityVaultIdMismatch: vector = b"The security_vault_id associated with the provided capability does not match the security_vault_id of the `RoleMap`"; +const ECapabilitySecurityVaultIdMismatch: vector = + b"The security_vault_id associated with the provided capability does not match the security_vault_id of the `RoleMap`"; #[error] -const ECapabilityTimeConstraintsNotMet: vector = b"The capability's time constraints are not currently met either due to `valid_from` or `valid_until` restrictions"; +const ECapabilityTimeConstraintsNotMet: vector = + b"The capability's time constraints are not currently met either due to `valid_from` or `valid_until` restrictions"; #[error] -const ECapabilityIssuedToMismatch: vector = b"The capability is restricted to a specific address which does not match the caller's address"; +const ECapabilityIssuedToMismatch: vector = + b"The capability is restricted to a specific address which does not match the caller's address"; #[error] -const ECapabilityPermissionDenied: vector = b"The role associated with provided capability does not have the required permission"; - +const ECapabilityPermissionDenied: vector = + b"The role associated with provided capability does not have the required permission"; // =============== Events ========================================================== @@ -68,11 +71,10 @@ public struct CapabilityDestroyed has copy, drop { public struct CapabilityRevoked has copy, drop { security_vault_id: ID, capability_id: ID, -} +} // TODO: Add event for Role creation, removing, updating, etc. - // =============== Core Types ====================================================== /// Defines the permissions required to administer roles in this RoleMap @@ -141,7 +143,7 @@ public fun new_capability_admin_permissions( /// The initial admin capability has no restrictions (no address, valid_from, or valid_until) /// The security_vault_id is associated with both the RoleMap and the initial admin capability /// Returns the newly created RoleMap and the initial admin capability -/// +/// /// Parameters /// ---------- /// - security_vault_id: @@ -167,7 +169,7 @@ public fun new( ): (RoleMap

, Capability) { let mut roles = vec_map::empty>(); roles.insert(initial_admin_role_name, initial_admin_role_permissions); - + let admin_cap = capability::new_capability_without_restrictions( initial_admin_role_name, security_vault_id, @@ -205,14 +207,15 @@ public fun rmap_create_role( clock: &Clock, ctx: &TxContext, ) { - assert!(role_map.is_capability_valid( + assert!( + role_map.is_capability_valid( cap, &role_map.role_admin_permissions.add, clock, - ctx + ctx, ), - EPermissionDenied - ); + EPermissionDenied, + ); vec_map::insert(&mut role_map.roles, role, permissions); } @@ -225,14 +228,15 @@ public fun rmap_delete_role( clock: &Clock, ctx: &TxContext, ) { - assert!(role_map.is_capability_valid( + assert!( + role_map.is_capability_valid( cap, &role_map.role_admin_permissions.delete, clock, - ctx + ctx, ), - EPermissionDenied - ); + EPermissionDenied, + ); vec_map::remove(&mut role_map.roles, role); } @@ -246,31 +250,29 @@ public fun rmap_update_role_permissions( clock: &Clock, ctx: &TxContext, ) { - assert!(role_map.is_capability_valid( + assert!( + role_map.is_capability_valid( cap, &role_map.role_admin_permissions.update, clock, - ctx + ctx, ), - EPermissionDenied - ); + EPermissionDenied, + ); assert!(vec_map::contains(&role_map.roles, role), ERoleDoesNotExist); vec_map::insert(&mut role_map.roles, *role, new_permissions); } /// Indicates if the specified role exists in the role_map -public fun rmap_has_role( - role_map: &RoleMap

, - role: &String, -): bool { +public fun rmap_has_role(role_map: &RoleMap

, role: &String): bool { vec_map::contains(&role_map.roles, role) } // =============== Capability related Functions ==================================== /// Indicates if a provided capability is valid. -/// +/// /// A capability is considered valid if: /// - The capability's security_vault_id matches the RoleMap's security_vault_id. /// Aborts with ECapabilitySecurityVaultIdMismatch if not matching. @@ -284,7 +286,7 @@ public fun rmap_has_role( /// Aborts with ECapabilityTimeConstraintsNotMet, if the current time is outside the valid_from and valid_until range. /// - If the capability is restricted to a specific address, the caller's address matches the sender of the transaction. /// Aborts with ECapabilityIssuedToMismatch if the addresses do not match. -/// +/// /// Parameters /// ---------- /// - role_map: Reference to the `RoleMap` mapping. @@ -292,7 +294,7 @@ public fun rmap_has_role( /// - permission: The permission to check against the capability's role. /// - clock: Reference to a Clock instance for time-based validation. /// - ctx: Reference to the transaction context for accessing the caller's address. -/// +/// /// Returns /// ------- /// - bool: true if the capability is valid, otherwise aborts with the relevant error. @@ -303,13 +305,16 @@ public fun rmap_is_capability_valid( clock: &Clock, ctx: &TxContext, ): bool { - assert!(role_map.security_vault_id == cap.security_vault_id(), ECapabilitySecurityVaultIdMismatch); + assert!( + role_map.security_vault_id == cap.security_vault_id(), + ECapabilitySecurityVaultIdMismatch, + ); let permissions = role_map.get_role_permissions(cap.role()); assert!(vec_set::contains(permissions, permission), ECapabilityPermissionDenied); assert!(role_map.issued_capabilities.contains(&cap.id()), ECapabilityHasBeenRevoked); - + if (cap.valid_from().is_some() || cap.valid_until().is_some()) { assert!(cap.is_currently_valid(clock), ECapabilityTimeConstraintsNotMet); }; @@ -335,11 +340,11 @@ public fun rmap_is_capability_valid( /// - valid_until: Optional end time (in seconds since Unix epoch) for the new capability. /// - clock: Reference to a Clock instance for time-based validation. /// - ctx: Reference to the transaction context. -/// +/// /// Returns the newly created capability. -/// +/// /// Sends a CapabilityIssued event upon successful creation. -/// +/// /// Errors: /// - Aborts with EPermissionDenied if the provided capability does not have the permission specified with `CapabilityAdminPermissions::add`. /// - Aborts with ERoleDoesNotExist if the specified role does not exist in the role_map. @@ -354,14 +359,15 @@ public fun rmap_new_capability( clock: &Clock, ctx: &mut TxContext, ): Capability { - assert!(role_map.is_capability_valid( + assert!( + role_map.is_capability_valid( cap, &role_map.capability_admin_permissions.add, clock, - ctx + ctx, ), - EPermissionDenied - ); + EPermissionDenied, + ); assert!(role_map.roles.contains(role), ERoleDoesNotExist); let new_cap = capability::new_capability( @@ -378,11 +384,11 @@ public fun rmap_new_capability( /// Create a new unrestricted capability with a specific role without any /// address, valid_from, or valid_until restrictions. -/// +/// /// Returns the newly created capability. -/// +/// /// Sends a CapabilityIssued event upon successful creation. -/// +/// /// Errors: /// - Aborts with EPermissionDenied if the provided capability does not have the permission specified with `CapabilityAdminPermissions::add`. /// - Aborts with ERoleDoesNotExist if the specified role does not exist in the role_map. @@ -393,14 +399,15 @@ public fun rmap_new_capability_without_restrictions( clock: &Clock, ctx: &mut TxContext, ): Capability { - assert!(role_map.is_capability_valid( + assert!( + role_map.is_capability_valid( cap, &role_map.capability_admin_permissions.add, clock, - ctx + ctx, ), - EPermissionDenied - ); + EPermissionDenied, + ); assert!(role_map.roles.contains(role), ERoleDoesNotExist); let new_cap = capability::new_capability_without_restrictions( @@ -414,11 +421,11 @@ public fun rmap_new_capability_without_restrictions( } /// Create a new capability with a specific role that expires at a given timestamp (seconds since Unix epoch). -/// +/// /// Returns the newly created capability. -/// +/// /// Sends a CapabilityIssued event upon successful creation. -/// +/// /// Errors: /// - Aborts with EPermissionDenied if the provided capability does not have the permission specified with `CapabilityAdminPermissions::add`. /// - Aborts with ERoleDoesNotExist if the specified role does not exist in the role_map. @@ -430,14 +437,15 @@ public fun rmap_new_capability_valid_until( clock: &Clock, ctx: &mut TxContext, ): Capability { - assert!(role_map.is_capability_valid( + assert!( + role_map.is_capability_valid( cap, &role_map.capability_admin_permissions.add, clock, - ctx + ctx, ), - EPermissionDenied - ); + EPermissionDenied, + ); assert!(role_map.roles.contains(role), ERoleDoesNotExist); let new_cap = capability::new_capability_valid_until( @@ -453,11 +461,11 @@ public fun rmap_new_capability_valid_until( /// Create a new capability with a specific role restricted to an address. /// Optionally set an expiration time (seconds since Unix epoch). -/// +/// /// Returns the newly created capability. -/// +/// /// Sends a CapabilityIssued event upon successful creation. -/// +/// /// Errors: /// - Aborts with EPermissionDenied if the provided capability does not have the permission specified with `CapabilityAdminPermissions::add`. /// - Aborts with ERoleDoesNotExist if the specified role does not exist in the role_map. @@ -470,14 +478,15 @@ public fun rmap_new_capability_for_address( clock: &Clock, ctx: &mut TxContext, ): Capability { - assert!(role_map.is_capability_valid( + assert!( + role_map.is_capability_valid( cap, &role_map.capability_admin_permissions.add, clock, - ctx + ctx, ), - EPermissionDenied - ); + EPermissionDenied, + ); assert!(role_map.roles.contains(role), ERoleDoesNotExist); let new_cap = capability::new_capability_for_address( @@ -494,9 +503,9 @@ public fun rmap_new_capability_for_address( /// Destroy an existing capability /// Every owner of a capability is allowed to destroy it when no longer needed. -/// +/// /// Sends a CapabilityDestroyed event upon successful destruction. -/// +/// /// TODO: Clarify if we need to restrict access with the `CapabilitiesRevoke` permission here. /// If yes, we also need a destroy function for Admin capabilities (without the need of another Admin capability). /// Otherwise the last Admin capability holder will block the role_map forever by not being able to destroy it. @@ -504,7 +513,10 @@ public fun rmap_destroy_capability( role_map: &mut RoleMap

, cap_to_destroy: Capability, ) { - assert!(role_map.security_vault_id == cap_to_destroy.security_vault_id(), ECapabilitySecurityVaultIdMismatch); + assert!( + role_map.security_vault_id == cap_to_destroy.security_vault_id(), + ECapabilitySecurityVaultIdMismatch, + ); role_map.issued_capabilities.remove(&cap_to_destroy.id()); event::emit(CapabilityDestroyed { @@ -520,9 +532,9 @@ public fun rmap_destroy_capability( } /// Revoke an existing capability -/// +/// /// Sends a CapabilityRevoked event upon successful revocation. -/// +/// /// Errors: /// - Aborts with EPermissionDenied if the provided capability does not have the permission specified with `CapabilityAdminPermissions::revoke`. /// - Aborts with ERoleDoesNotExist if the specified role does not exist in the `RoleMap.issued_capabilities()` list. @@ -533,13 +545,14 @@ public fun rmap_revoke_capability( clock: &Clock, ctx: &TxContext, ) { - assert!(role_map.is_capability_valid( + assert!( + role_map.is_capability_valid( cap, &role_map.capability_admin_permissions.revoke, clock, - ctx + ctx, ), - EPermissionDenied + EPermissionDenied, ); assert!(role_map.issued_capabilities.contains(&cap_to_revoke), ERoleDoesNotExist); @@ -551,10 +564,7 @@ public fun rmap_revoke_capability( }); } -fun register_new_capability( - role_map: &mut RoleMap

, - new_cap: &Capability, -) { +fun register_new_capability(role_map: &mut RoleMap

, new_cap: &Capability) { role_map.issued_capabilities.insert(new_cap.id()); event::emit(CapabilityIssued { @@ -580,13 +590,13 @@ public fun rmap_security_vault_id(role_map: &RoleMap

): ID { } //Returns the role admin permissions associated with the role_map -public fun rmap_role_admin_permissions(role_map: &RoleMap

): &RoleAdminPermissions

{ +public fun rmap_role_admin_permissions( + role_map: &RoleMap

, +): &RoleAdminPermissions

{ &role_map.role_admin_permissions } -public fun rmap_issued_capabilities( - role_map: &RoleMap

, -): &VecSet { +public fun rmap_issued_capabilities(role_map: &RoleMap

): &VecSet { &role_map.issued_capabilities } @@ -602,7 +612,8 @@ public use fun rmap_security_vault_id as RoleMap.security_vault_id; public use fun rmap_role_admin_permissions as RoleMap.role_admin_permissions; public use fun rmap_is_capability_valid as RoleMap.is_capability_valid; public use fun rmap_new_capability as RoleMap.new_capability; -public use fun rmap_new_capability_without_restrictions as RoleMap.new_capability_without_restrictions; +public use fun rmap_new_capability_without_restrictions as + RoleMap.new_capability_without_restrictions; public use fun rmap_new_capability_valid_until as RoleMap.new_capability_valid_until; public use fun rmap_new_capability_for_address as RoleMap.new_capability_for_address; public use fun rmap_destroy_capability as RoleMap.destroy_capability; diff --git a/audit-trail-move/tests/capability_tests.move b/audit-trail-move/tests/capability_tests.move index ff8181f9..1065cf83 100644 --- a/audit-trail-move/tests/capability_tests.move +++ b/audit-trail-move/tests/capability_tests.move @@ -1,13 +1,21 @@ #[test_only] module audit_trail::capability_tests; -use audit_trail::permission::{Self}; -use audit_trail::locking::{Self}; -use audit_trail::main::{AuditTrail}; -use audit_trail::test_utils::{Self, TestData, setup_test_audit_trail, fetch_capability_trail_and_clock, cleanup_capability_trail_and_clock}; +use audit_trail::{ + capability::Capability, + locking, + main::AuditTrail, + permission, + test_utils::{ + Self, + TestData, + setup_test_audit_trail, + fetch_capability_trail_and_clock, + cleanup_capability_trail_and_clock + } +}; use iota::test_scenario::{Self as ts, Scenario}; -use std::string::{Self}; -use audit_trail::capability::Capability; +use std::string; /// Helper function to setup an audit trail with a RecordAdmin role and a capability /// with a time window restriction transferred to the record_user. @@ -19,7 +27,7 @@ fun setup_trail_with_record_admin_capability_and_time_window_restriction( valid_from_secs: u64, valid_until_secs: u64, ): ID { - // Setup + // Setup let trail_id = setup_trail_with_record_admin_role(scenario, admin_user); // Issue capability with time window @@ -27,15 +35,17 @@ fun setup_trail_with_record_admin_capability_and_time_window_restriction( { let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(scenario); - let cap = trail.roles_mut().new_capability( - &admin_cap, - &string::utf8(b"RecordAdmin"), - std::option::none(), // no address restriction - std::option::some(valid_from_secs), - std::option::some(valid_until_secs), - &clock, - ts::ctx(scenario), - ); + let cap = trail + .roles_mut() + .new_capability( + &admin_cap, + &string::utf8(b"RecordAdmin"), + std::option::none(), // no address restriction + std::option::some(valid_from_secs), + std::option::some(valid_until_secs), + &clock, + ts::ctx(scenario), + ); // Verify capability properties assert!(cap.issued_to().is_none(), 0); @@ -49,13 +59,9 @@ fun setup_trail_with_record_admin_capability_and_time_window_restriction( trail_id } - /// Helper function to setup an audit trail with a RecordAdmin role. /// Returns the trail_id. -fun setup_trail_with_record_admin_role( - scenario: &mut Scenario, - admin_user: address, -): ID { +fun setup_trail_with_record_admin_role(scenario: &mut Scenario, admin_user: address): ID { // Setup: Create audit trail with admin capability let trail_id = { let locking_config = locking::new(locking::window_count_based(0)); @@ -76,16 +82,18 @@ fun setup_trail_with_record_admin_role( let admin_cap = ts::take_from_sender(scenario); let mut trail = ts::take_shared>(scenario); let clock = iota::clock::create_for_testing(ts::ctx(scenario)); - + let record_admin_perms = permission::record_admin_permissions(); - trail.roles_mut().create_role( - &admin_cap, - string::utf8(b"RecordAdmin"), - record_admin_perms, - &clock, - ts::ctx(scenario), - ); - + trail + .roles_mut() + .create_role( + &admin_cap, + string::utf8(b"RecordAdmin"), + record_admin_perms, + &clock, + ts::ctx(scenario), + ); + iota::clock::destroy_for_testing(clock); ts::return_to_sender(scenario, admin_cap); ts::return_shared(trail); @@ -119,18 +127,20 @@ fun test_new_capability() { // Verify initial state - only admin capability should be tracked let initial_cap_count = trail.roles().issued_capabilities().size(); assert!(initial_cap_count == 1, 0); // Only admin cap - - let cap1 = trail.roles_mut().new_capability_without_restrictions( - &admin_cap, - &string::utf8(b"RecordAdmin"), - &clock, - ts::ctx(&mut scenario), - ); + + let cap1 = trail + .roles_mut() + .new_capability_without_restrictions( + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); // Verify capability was created correctly assert!(cap1.role() == string::utf8(b"RecordAdmin"), 1); assert!(cap1.security_vault_id() == trail_id, 2); - + let cap1_id = object::id(&cap1); // Verify capability ID is tracked in issued_capabilities @@ -150,12 +160,14 @@ fun test_new_capability() { let previous_cap_count = trail.roles().issued_capabilities().size(); - let cap2 = trail.roles_mut().new_capability_without_restrictions( - &admin_cap, - &string::utf8(b"RecordAdmin"), - &clock, - ts::ctx(&mut scenario), - ); + let cap2 = trail + .roles_mut() + .new_capability_without_restrictions( + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); let cap2_id = object::id(&cap2); @@ -163,13 +175,13 @@ fun test_new_capability() { assert!(trail.roles().issued_capabilities().size() == previous_cap_count + 1, 5); assert!(trail.roles().issued_capabilities().contains(&cap1_id), 6); assert!(trail.roles().issued_capabilities().contains(&cap2_id), 7); - + // Verify capabilities have unique IDs assert!(cap1_id != cap2_id, 8); transfer::public_transfer(cap2, user2); cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); - + cap2_id }; @@ -190,34 +202,38 @@ fun test_revoke_capability() { let user2 = @0xCAB; let mut scenario = ts::begin(admin_user); - + let _trail_id = setup_trail_with_record_admin_role(&mut scenario, admin_user); - + // Issue two capabilities ts::next_tx(&mut scenario, admin_user); let (cap1_id, cap2_id) = { let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); - let cap1 = trail.roles_mut().new_capability_without_restrictions( - &admin_cap, - &string::utf8(b"RecordAdmin"), - &clock, - ts::ctx(&mut scenario), - ); + let cap1 = trail + .roles_mut() + .new_capability_without_restrictions( + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); let cap1_id = object::id(&cap1); transfer::public_transfer(cap1, user1); - - let cap2 = trail.roles_mut().new_capability_without_restrictions( - &admin_cap, - &string::utf8(b"RecordAdmin"), - &clock, - ts::ctx(&mut scenario), - ); + + let cap2 = trail + .roles_mut() + .new_capability_without_restrictions( + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); let cap2_id = object::id(&cap2); transfer::public_transfer(cap2, user2); - + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); - + (cap1_id, cap2_id) }; @@ -226,19 +242,21 @@ fun test_revoke_capability() { { let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); let cap1 = ts::take_from_address(&scenario, user1); - + // Verify both capabilities are tracked before revocation let cap_count_before = trail.roles().issued_capabilities().size(); assert!(trail.roles().issued_capabilities().contains(&cap1_id), 0); assert!(trail.roles().issued_capabilities().contains(&cap2_id), 1); - + // Revoke the capability - trail.roles_mut().revoke_capability( - &admin_cap, - cap1.id(), - &clock, - ts::ctx(&mut scenario), - ); + trail + .roles_mut() + .revoke_capability( + &admin_cap, + cap1.id(), + &clock, + ts::ctx(&mut scenario), + ); // Verify capability was removed from tracking assert!(trail.roles().issued_capabilities().size() == cap_count_before - 1, 2); @@ -265,12 +283,14 @@ fun test_revoke_capability() { let cap_count_before = trail.roles().issued_capabilities().size(); - trail.roles_mut().revoke_capability( - &admin_cap, - cap2.id(), - &clock, - ts::ctx(&mut scenario), - ); + trail + .roles_mut() + .revoke_capability( + &admin_cap, + cap2.id(), + &clock, + ts::ctx(&mut scenario), + ); // Verify capability was removed from tracking assert!(trail.roles().issued_capabilities().size() == cap_count_before - 1, 6); @@ -297,34 +317,38 @@ fun test_destroy_capability() { let user2 = @0xCAB; let mut scenario = ts::begin(admin_user); - + let _trail_id = setup_trail_with_record_admin_role(&mut scenario, admin_user); - + // Issue two capabilities ts::next_tx(&mut scenario, admin_user); let (cap1_id, cap2_id) = { let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); - let cap1 = trail.roles_mut().new_capability_without_restrictions( - &admin_cap, - &string::utf8(b"RecordAdmin"), - &clock, - ts::ctx(&mut scenario), - ); + let cap1 = trail + .roles_mut() + .new_capability_without_restrictions( + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); let cap1_id = object::id(&cap1); transfer::public_transfer(cap1, user1); - - let cap2 = trail.roles_mut().new_capability_without_restrictions( - &admin_cap, - &string::utf8(b"RecordAdmin"), - &clock, - ts::ctx(&mut scenario), - ); + + let cap2 = trail + .roles_mut() + .new_capability_without_restrictions( + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); let cap2_id = object::id(&cap2); transfer::public_transfer(cap2, user2); - + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); - + (cap1_id, cap2_id) }; @@ -338,17 +362,17 @@ fun test_destroy_capability() { let cap_count_before = trail.roles().issued_capabilities().size(); assert!(trail.roles().issued_capabilities().contains(&cap1_id), 0); assert!(trail.roles().issued_capabilities().contains(&cap2_id), 1); - + // Destroy the capability trail.roles_mut().destroy_capability(cap1); - + // Verify capability was removed from tracking assert!(trail.roles().issued_capabilities().size() == cap_count_before - 1, 2); assert!(!trail.roles().issued_capabilities().contains(&cap1_id), 3); - + // Verify other capability is still tracked assert!(trail.roles().issued_capabilities().contains(&cap2_id), 4); - + ts::return_shared(trail); }; @@ -364,11 +388,11 @@ fun test_destroy_capability() { { let mut trail = ts::take_shared>(&scenario); let cap2 = ts::take_from_sender(&scenario); - + let cap_count_before = trail.roles().issued_capabilities().size(); trail.roles_mut().destroy_capability(cap2); - + // Verify capability was removed from tracking assert!(trail.roles().issued_capabilities().size() == cap_count_before - 1, 6); assert!(!trail.roles().issued_capabilities().contains(&cap2_id), 7); @@ -383,7 +407,7 @@ fun test_destroy_capability() { // Only the initial admin capability should remain assert!(trail.roles().issued_capabilities().size() == 1, 8); - + ts::return_shared(trail); }; @@ -408,23 +432,24 @@ fun test_capability_lifecycle() { // Setup: Create audit trail let _trail_id = setup_trail_with_record_admin_role(&mut scenario, admin_user); - // Create an additional RoleAdmin role ts::next_tx(&mut scenario, admin_user); { let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); - + // Initially only admin cap should be tracked assert!(trail.roles().issued_capabilities().size() == 1, 0); - trail.roles_mut().create_role( - &admin_cap, - string::utf8(b"RoleAdmin"), - permission::role_admin_permissions(), - &clock, - ts::ctx(&mut scenario), - ); - + trail + .roles_mut() + .create_role( + &admin_cap, + string::utf8(b"RoleAdmin"), + permission::role_admin_permissions(), + &clock, + ts::ctx(&mut scenario), + ); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); }; @@ -433,21 +458,25 @@ fun test_capability_lifecycle() { let (record_cap_id, role_cap_id) = { let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); - let record_cap = trail.roles_mut().new_capability_without_restrictions( - &admin_cap, - &string::utf8(b"RecordAdmin"), - &clock, - ts::ctx(&mut scenario), - ); + let record_cap = trail + .roles_mut() + .new_capability_without_restrictions( + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); let record_cap_id = object::id(&record_cap); transfer::public_transfer(record_cap, record_admin_user); - - let role_cap = trail.roles_mut().new_capability_without_restrictions( - &admin_cap, - &string::utf8(b"RoleAdmin"), - &clock, - ts::ctx(&mut scenario), - ); + + let role_cap = trail + .roles_mut() + .new_capability_without_restrictions( + &admin_cap, + &string::utf8(b"RoleAdmin"), + &clock, + ts::ctx(&mut scenario), + ); let role_cap_id = object::id(&role_cap); transfer::public_transfer(role_cap, role_admin_user); @@ -457,7 +486,7 @@ fun test_capability_lifecycle() { assert!(trail.roles().issued_capabilities().contains(&role_cap_id), 3); cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); - + (record_cap_id, role_cap_id) }; @@ -476,7 +505,7 @@ fun test_capability_lifecycle() { &clock, ts::ctx(&mut scenario), ); - + cleanup_capability_trail_and_clock(&scenario, record_cap, trail, clock); }; @@ -485,9 +514,9 @@ fun test_capability_lifecycle() { { let mut trail = ts::take_shared>(&scenario); let record_cap = ts::take_from_sender(&scenario); - + trail.roles_mut().destroy_capability(record_cap); - + // Verify capability was removed assert!(trail.roles().issued_capabilities().size() == 2, 4); // admin + role assert!(!trail.roles().issued_capabilities().contains(&record_cap_id), 5); @@ -501,12 +530,14 @@ fun test_capability_lifecycle() { let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); let role_cap = ts::take_from_address(&scenario, role_admin_user); - trail.roles_mut().revoke_capability( - &admin_cap, - role_cap.id(), - &clock, - ts::ctx(&mut scenario), - ); + trail + .roles_mut() + .revoke_capability( + &admin_cap, + role_cap.id(), + &clock, + ts::ctx(&mut scenario), + ); // Verify capability was removed assert!(trail.roles().issued_capabilities().size() == 1, 6); // only admin remains @@ -515,7 +546,6 @@ fun test_capability_lifecycle() { ts::return_to_address(role_admin_user, role_cap); cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); - }; ts::end(scenario); @@ -542,14 +572,16 @@ fun test_capability_issued_to_only() { { let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); - let cap = trail.roles_mut().new_capability_for_address( - &admin_cap, - &string::utf8(b"RecordAdmin"), - authorized_user, - std::option::none(), // no time restriction - &clock, - ts::ctx(&mut scenario), - ); + let cap = trail + .roles_mut() + .new_capability_for_address( + &admin_cap, + &string::utf8(b"RecordAdmin"), + authorized_user, + std::option::none(), // no time restriction + &clock, + ts::ctx(&mut scenario), + ); // Verify capability properties assert!(cap.issued_to() == std::option::some(authorized_user), 0); @@ -565,7 +597,6 @@ fun test_capability_issued_to_only() { { let (record_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); - let test_data = test_utils::new_test_data(1, b"Authorized record"); trail.add_record( &record_cap, @@ -595,7 +626,7 @@ fun test_capability_issued_to_only() { test_data, std::option::none(), &clock, - ts::ctx(&mut scenario) + ts::ctx(&mut scenario), ); cleanup_capability_trail_and_clock(&scenario, record_cap, trail, clock); @@ -627,15 +658,17 @@ fun test_capability_valid_from_only() { { let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); - let cap = trail.roles_mut().new_capability( - &admin_cap, - &string::utf8(b"RecordAdmin"), - std::option::none(), // no address restriction - std::option::some(valid_from_time), - std::option::none(), // no valid_until - &clock, - ts::ctx(&mut scenario) - ); + let cap = trail + .roles_mut() + .new_capability( + &admin_cap, + &string::utf8(b"RecordAdmin"), + std::option::none(), // no address restriction + std::option::some(valid_from_time), + std::option::none(), // no valid_until + &clock, + ts::ctx(&mut scenario), + ); // Verify capability properties assert!(cap.issued_to().is_none(), 0); @@ -664,7 +697,7 @@ fun test_capability_valid_from_only() { cleanup_capability_trail_and_clock(&scenario, cap, trail, clock); }; - // Try to use the capability before valid_from + // Try to use the capability before valid_from ts::next_tx(&mut scenario, user); { let (cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); @@ -699,7 +732,7 @@ fun test_capability_valid_until_only() { let mut scenario = ts::begin(admin_user); - let valid_until_time_secs= test_utils::initial_time_for_testing() / 1000 + 10; + let valid_until_time_secs = test_utils::initial_time_for_testing() / 1000 + 10; // Setup let _trail_id = setup_trail_with_record_admin_role(&mut scenario, admin_user); @@ -709,13 +742,15 @@ fun test_capability_valid_until_only() { { let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); - let cap = trail.roles_mut().new_capability_valid_until( - &admin_cap, - &string::utf8(b"RecordAdmin"), - valid_until_time_secs, - &clock, - ts::ctx(&mut scenario), - ); + let cap = trail + .roles_mut() + .new_capability_valid_until( + &admin_cap, + &string::utf8(b"RecordAdmin"), + valid_until_time_secs, + &clock, + ts::ctx(&mut scenario), + ); // Verify capability properties assert!(cap.issued_to().is_none(), 0); @@ -766,7 +801,6 @@ fun test_capability_valid_until_only() { ts::end(scenario); } - /// Test capability with valid_from and valid_until restrictions (time window). /// /// This test validates: @@ -823,8 +857,8 @@ fun test_capability_time_window_before_valid_from() { let mut scenario = ts::begin(admin_user); - let valid_from_time_secs= test_utils::initial_time_for_testing() / 1000 + 5; - let valid_until_time_secs= test_utils::initial_time_for_testing() / 1000 + 10; + let valid_from_time_secs = test_utils::initial_time_for_testing() / 1000 + 5; + let valid_until_time_secs = test_utils::initial_time_for_testing() / 1000 + 10; // Setup let _trail_id = setup_trail_with_record_admin_capability_and_time_window_restriction( @@ -867,8 +901,8 @@ fun test_capability_time_window_after_valid_until() { let mut scenario = ts::begin(admin_user); - let valid_from_time_secs= test_utils::initial_time_for_testing() / 1000 + 5; - let valid_until_time_secs= test_utils::initial_time_for_testing() / 1000 + 10; + let valid_from_time_secs = test_utils::initial_time_for_testing() / 1000 + 5; + let valid_until_time_secs = test_utils::initial_time_for_testing() / 1000 + 10; // Setup let _trail_id = setup_trail_with_record_admin_capability_and_time_window_restriction( @@ -926,15 +960,17 @@ fun test_is_valid_for_timestamp() { { let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); - let cap = trail.roles_mut().new_capability( - &admin_cap, - &string::utf8(b"RecordAdmin"), - std::option::none(), - std::option::some(valid_from_time), - std::option::some(valid_until_time), - &clock, - ts::ctx(&mut scenario), - ); + let cap = trail + .roles_mut() + .new_capability( + &admin_cap, + &string::utf8(b"RecordAdmin"), + std::option::none(), + std::option::some(valid_from_time), + std::option::some(valid_until_time), + &clock, + ts::ctx(&mut scenario), + ); // Before valid_from assert!(!cap.is_valid_for_timestamp(valid_from_time - 1), 0); @@ -963,12 +999,14 @@ fun test_is_valid_for_timestamp() { { let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); - let unrestricted_cap = trail.roles_mut().new_capability_without_restrictions( - &admin_cap, - &string::utf8(b"RecordAdmin"), - &clock, - ts::ctx(&mut scenario), - ); + let unrestricted_cap = trail + .roles_mut() + .new_capability_without_restrictions( + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); // Should be valid at any timestamp assert!(unrestricted_cap.is_valid_for_timestamp(0), 6); @@ -1007,15 +1045,17 @@ fun test_is_currently_valid() { { let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); - let cap = trail.roles_mut().new_capability( - &admin_cap, - &string::utf8(b"RecordAdmin"), - std::option::none(), - std::option::some(valid_from_time / 1000), - std::option::some(valid_until_time / 1000), - &clock, - ts::ctx(&mut scenario), - ); + let cap = trail + .roles_mut() + .new_capability( + &admin_cap, + &string::utf8(b"RecordAdmin"), + std::option::none(), + std::option::some(valid_from_time / 1000), + std::option::some(valid_until_time / 1000), + &clock, + ts::ctx(&mut scenario), + ); transfer::public_transfer(cap, user); cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); @@ -1084,12 +1124,14 @@ fun test_new_capability_without_restrictions() { { let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); - let cap = trail.roles_mut().new_capability_without_restrictions( - &admin_cap, - &string::utf8(b"RecordAdmin"), - &clock, - ts::ctx(&mut scenario), - ); + let cap = trail + .roles_mut() + .new_capability_without_restrictions( + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); // Verify no restrictions assert!(cap.issued_to().is_none(), 0); @@ -1146,13 +1188,15 @@ fun test_new_capability_valid_until() { { let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); - let cap = trail.roles_mut().new_capability_valid_until( - &admin_cap, - &string::utf8(b"RecordAdmin"), - valid_until_time, - &clock, - ts::ctx(&mut scenario), - ); + let cap = trail + .roles_mut() + .new_capability_valid_until( + &admin_cap, + &string::utf8(b"RecordAdmin"), + valid_until_time, + &clock, + ts::ctx(&mut scenario), + ); // Verify restrictions assert!(cap.issued_to().is_none(), 0); @@ -1189,14 +1233,16 @@ fun test_new_capability_for_address_no_expiration() { { let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); - let cap = trail.roles_mut().new_capability_for_address( - &admin_cap, - &string::utf8(b"RecordAdmin"), - authorized_user, - std::option::none(), // no expiration - &clock, - ts::ctx(&mut scenario), - ); + let cap = trail + .roles_mut() + .new_capability_for_address( + &admin_cap, + &string::utf8(b"RecordAdmin"), + authorized_user, + std::option::none(), // no expiration + &clock, + ts::ctx(&mut scenario), + ); // Verify restrictions assert!(cap.issued_to() == std::option::some(authorized_user), 0); diff --git a/audit-trail-move/tests/create_audit_trail_tests.move b/audit-trail-move/tests/create_audit_trail_tests.move index 79f3c53f..33281a3f 100644 --- a/audit-trail-move/tests/create_audit_trail_tests.move +++ b/audit-trail-move/tests/create_audit_trail_tests.move @@ -2,12 +2,20 @@ /// This module contains comprehensive tests for the AuditTrail creation functionality. module audit_trail::create_audit_trail_tests; -use audit_trail::main::{Self, AuditTrail, initial_admin_role_name}; -use audit_trail::locking::{Self}; -use audit_trail::test_utils::{setup_test_audit_trail, new_test_data, initial_time_for_testing, TestData, fetch_capability_trail_and_clock, cleanup_capability_trail_and_clock}; -use iota::test_scenario::{Self as ts}; -use iota::clock::{Self}; -use std::string::{Self}; +use audit_trail::{ + locking, + main::{Self, AuditTrail, initial_admin_role_name}, + test_utils::{ + setup_test_audit_trail, + new_test_data, + initial_time_for_testing, + TestData, + fetch_capability_trail_and_clock, + cleanup_capability_trail_and_clock + } +}; +use iota::{clock, test_scenario as ts}; +use std::string; /// Goals of this test: /// - Verifies creating an AuditTrail with no initial record @@ -30,7 +38,7 @@ fun test_create_without_initial_record() { // Verify capability was created assert!(admin_cap.role() == initial_admin_role_name(), 0); assert!(admin_cap.security_vault_id() == trail_id, 1); - + // Clean up admin_cap.destroy_for_testing(); }; @@ -72,7 +80,7 @@ fun test_create_with_initial_record() { // Verify capability assert!(admin_cap.role() == initial_admin_role_name(), 0); assert!(admin_cap.security_vault_id() == trail_id, 1); - + // Clean up admin_cap.destroy_for_testing(); }; @@ -254,7 +262,7 @@ fun test_create_metadata_admin_role() { // Verify admin capability was created assert!(admin_cap.role() == initial_admin_role_name(), 0); assert!(admin_cap.security_vault_id() == trail_id, 1); - + // Transfer the admin capability to the user transfer::public_transfer(admin_cap, user); }; @@ -266,18 +274,20 @@ fun test_create_metadata_admin_role() { // Create the MetadataAdmin role using the admin capability let metadata_admin_role_name = string::utf8(b"MetadataAdmin"); let metadata_admin_perms = audit_trail::permission::metadata_admin_permissions(); - - trail.roles_mut().create_role( - &admin_cap, - metadata_admin_role_name, - metadata_admin_perms, - &clock, - ts::ctx(&mut scenario), - ); + + trail + .roles_mut() + .create_role( + &admin_cap, + metadata_admin_role_name, + metadata_admin_perms, + &clock, + ts::ctx(&mut scenario), + ); // Verify the role was created by fetching its permissions let role_perms = trail.roles().get_role_permissions(&string::utf8(b"MetadataAdmin")); - + // Verify the role has the correct permissions assert!( audit_trail::permission::has_permission( diff --git a/audit-trail-move/tests/role_tests.move b/audit-trail-move/tests/role_tests.move index d834d59c..45e290f0 100644 --- a/audit-trail-move/tests/role_tests.move +++ b/audit-trail-move/tests/role_tests.move @@ -1,12 +1,19 @@ #[test_only] module audit_trail::role_tests; -use audit_trail::permission::{Self}; -use audit_trail::locking::{Self}; -use audit_trail::main::{initial_admin_role_name}; -use audit_trail::test_utils::{Self, setup_test_audit_trail, fetch_capability_trail_and_clock, cleanup_capability_trail_and_clock}; -use iota::test_scenario::{Self as ts}; -use std::string::{Self}; +use audit_trail::{ + locking, + main::initial_admin_role_name, + permission, + test_utils::{ + Self, + setup_test_audit_trail, + fetch_capability_trail_and_clock, + cleanup_capability_trail_and_clock + } +}; +use iota::test_scenario as ts; +use std::string; /// Test comprehensive role-based access control delegation workflow. /// @@ -29,57 +36,61 @@ fun test_role_based_permission_delegation() { let role_admin_user = @0xB0B; let cap_admin_user = @0xCAB; let record_admin_user = @0xDED; - + let mut scenario = ts::begin(admin_user); - + // Step 1: admin_user creates the audit trail let trail_id = { let locking_config = locking::new(locking::window_count_based(0)); - + let (admin_cap, trail_id) = setup_test_audit_trail( &mut scenario, locking_config, - std::option::none() + std::option::none(), ); - + // Verify admin capability was created with correct role and trail reference assert!(admin_cap.role() == initial_admin_role_name(), 0); assert!(admin_cap.security_vault_id() == trail_id, 1); - + // Transfer the admin capability to the user - transfer::public_transfer(admin_cap, admin_user); + transfer::public_transfer(admin_cap, admin_user); trail_id }; - + // Step 2: Admin creates RoleAdmin and CapAdmin roles ts::next_tx(&mut scenario, admin_user); { let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); - + // Verify initial state - should only have the initial admin role assert!(trail.roles().size() == 1, 2); // Create RoleAdmin role let role_admin_perms = permission::role_admin_permissions(); - trail.roles_mut().create_role( - &admin_cap, - string::utf8(b"RoleAdmin"), - role_admin_perms, - &clock, - ts::ctx(&mut scenario), - ); + trail + .roles_mut() + .create_role( + &admin_cap, + string::utf8(b"RoleAdmin"), + role_admin_perms, + &clock, + ts::ctx(&mut scenario), + ); // Create CapAdmin role let cap_admin_perms = permission::cap_admin_permissions(); - trail.roles_mut().create_role( - &admin_cap, - string::utf8(b"CapAdmin"), - cap_admin_perms, - &clock, - ts::ctx(&mut scenario), - ); - + trail + .roles_mut() + .create_role( + &admin_cap, + string::utf8(b"CapAdmin"), + cap_admin_perms, + &clock, + ts::ctx(&mut scenario), + ); + // Verify both roles were created assert!(trail.roles().size() == 3, 3); // Initial admin + RoleAdmin + CapAdmin assert!(trail.roles().has_role(&string::utf8(b"RoleAdmin")), 4); @@ -87,32 +98,36 @@ fun test_role_based_permission_delegation() { cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); }; - + // Step 3: Admin creates capability for RoleAdmin and CapAdmin and transfers to the respective users ts::next_tx(&mut scenario, admin_user); { let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); - let role_admin_cap = trail.roles_mut().new_capability_without_restrictions( - &admin_cap, - &string::utf8(b"RoleAdmin"), - &clock, - ts::ctx(&mut scenario), - ); - + let role_admin_cap = trail + .roles_mut() + .new_capability_without_restrictions( + &admin_cap, + &string::utf8(b"RoleAdmin"), + &clock, + ts::ctx(&mut scenario), + ); + // Verify the capability was created with correct role and trail ID assert!(role_admin_cap.role() == string::utf8(b"RoleAdmin"), 6); assert!(role_admin_cap.security_vault_id() == trail_id, 7); iota::transfer::public_transfer(role_admin_cap, role_admin_user); - let cap_admin_cap = trail.roles_mut().new_capability_without_restrictions( - &admin_cap, - &string::utf8(b"CapAdmin"), - &clock, - ts::ctx(&mut scenario), - ); - + let cap_admin_cap = trail + .roles_mut() + .new_capability_without_restrictions( + &admin_cap, + &string::utf8(b"CapAdmin"), + &clock, + ts::ctx(&mut scenario), + ); + // Verify the capability was created with correct role and trail ID assert!(cap_admin_cap.role() == string::utf8(b"CapAdmin"), 8); assert!(cap_admin_cap.security_vault_id() == trail_id, 9); @@ -122,31 +137,32 @@ fun test_role_based_permission_delegation() { cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); }; - // Step 5: RoleAdmin creates RecordAdmin role (demonstrating delegated role management) ts::next_tx(&mut scenario, role_admin_user); { let (role_admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); - + // Verify RoleAdmin has the correct role assert!(role_admin_cap.role() == string::utf8(b"RoleAdmin"), 10); let record_admin_perms = permission::record_admin_permissions(); - trail.roles_mut().create_role( - &role_admin_cap, - string::utf8(b"RecordAdmin"), - record_admin_perms, - &clock, - ts::ctx(&mut scenario), - ); - + trail + .roles_mut() + .create_role( + &role_admin_cap, + string::utf8(b"RecordAdmin"), + record_admin_perms, + &clock, + ts::ctx(&mut scenario), + ); + // Verify RecordAdmin role was created successfully assert!(trail.roles().size() == 4, 11); // Initial admin + RoleAdmin + CapAdmin + RecordAdmin assert!(trail.roles().has_role(&string::utf8(b"RecordAdmin")), 12); cleanup_capability_trail_and_clock(&scenario, role_admin_cap, trail, clock); }; - + // Step 6: CapAdmin creates capability for RecordAdmin and transfers to record_admin_user ts::next_tx(&mut scenario, cap_admin_user); { @@ -155,13 +171,15 @@ fun test_role_based_permission_delegation() { // Verify CapAdmin has the correct role assert!(cap_admin_cap.role() == string::utf8(b"CapAdmin"), 13); - let record_admin_cap = trail.roles_mut().new_capability_without_restrictions( - &cap_admin_cap, - &string::utf8(b"RecordAdmin"), - &clock, - ts::ctx(&mut scenario), - ); - + let record_admin_cap = trail + .roles_mut() + .new_capability_without_restrictions( + &cap_admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); + // Verify the capability was created with correct role and trail ID assert!(record_admin_cap.role() == string::utf8(b"RecordAdmin"), 14); assert!(record_admin_cap.security_vault_id() == trail_id, 15); @@ -170,11 +188,13 @@ fun test_role_based_permission_delegation() { cleanup_capability_trail_and_clock(&scenario, cap_admin_cap, trail, clock); }; - + // Step 7: RecordAdmin adds a new record to the audit trail (demonstrating delegated record management) ts::next_tx(&mut scenario, record_admin_user); { - let (record_admin_cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); + let (record_admin_cap, mut trail, mut clock) = fetch_capability_trail_and_clock( + &mut scenario, + ); clock.set_for_testing(test_utils::initial_time_for_testing() + 1000); // Verify RecordAdmin has the correct role @@ -183,7 +203,6 @@ fun test_role_based_permission_delegation() { // Verify initial record count let initial_record_count = trail.records().length(); - let test_data = test_utils::new_test_data(42, b"Test record added by RecordAdmin"); trail.add_record( @@ -193,14 +212,14 @@ fun test_role_based_permission_delegation() { &clock, ts::ctx(&mut scenario), ); - + // Verify the record was added successfully assert!(trail.records().length() == initial_record_count + 1, 17); cleanup_capability_trail_and_clock(&scenario, record_admin_cap, trail, clock); }; - + // Cleanup ts::next_tx(&mut scenario, admin_user); ts::end(scenario); -} \ No newline at end of file +} diff --git a/audit-trail-move/tests/test_utils.move b/audit-trail-move/tests/test_utils.move index c9bccc61..9aadd3ea 100644 --- a/audit-trail-move/tests/test_utils.move +++ b/audit-trail-move/tests/test_utils.move @@ -1,21 +1,14 @@ - #[test_only] module audit_trail::test_utils; -use std::string::{Self}; - -use iota::clock::{Self, Clock}; - -use iota::test_scenario::{Self as ts, Scenario}; - -use audit_trail::locking::{Self}; -use audit_trail::capability::{Capability}; -use audit_trail::main::{Self, AuditTrail}; +use audit_trail::{capability::Capability, locking, main::{Self, AuditTrail}}; +use iota::{clock::{Self, Clock}, test_scenario::{Self as ts, Scenario}}; +use std::string; const INITIAL_TIME_FOR_TESTING: u64 = 1234; /// Test data type for audit trail records -public struct TestData has store, copy, drop { +public struct TestData has copy, drop, store { value: u64, message: vector, } @@ -32,16 +25,20 @@ public(package) fun initial_time_for_testing(): u64 { } /// Setup a test audit trail with optional initial data -public(package) fun setup_test_audit_trail(scenario: &mut Scenario, locking_config: locking::LockingConfig, initial_data: Option): (Capability, iota::object::ID) { +public(package) fun setup_test_audit_trail( + scenario: &mut Scenario, + locking_config: locking::LockingConfig, + initial_data: Option, +): (Capability, iota::object::ID) { let (admin_cap, trail_id) = { let mut clock = clock::create_for_testing(ts::ctx(scenario)); clock.set_for_testing(INITIAL_TIME_FOR_TESTING); - + let trail_metadata = main::new_trail_metadata( std::option::some(string::utf8(b"Setup Test Trail")), std::option::none(), ); - + let (admin_cap, trail_id) = main::create( initial_data, std::option::none(), @@ -51,15 +48,17 @@ public(package) fun setup_test_audit_trail(scenario: &mut Scenario, locking_conf &clock, ts::ctx(scenario), ); - + clock::destroy_for_testing(clock); (admin_cap, trail_id) }; - + (admin_cap, trail_id) } -public(package) fun fetch_capability_trail_and_clock(scenario: &mut Scenario): (Capability, AuditTrail, Clock) { +public(package) fun fetch_capability_trail_and_clock( + scenario: &mut Scenario, +): (Capability, AuditTrail, Clock) { let admin_cap = ts::take_from_sender(scenario); let trail = ts::take_shared>(scenario); let clock = iota::clock::create_for_testing(ts::ctx(scenario)); From 8a5297b77e71df1764db257fa3d50e7a45bcbc2c Mon Sep 17 00:00:00 2001 From: chrisgitiota Date: Fri, 9 Jan 2026 18:35:13 +0100 Subject: [PATCH 025/189] Only revoke cap_to_destroy if they are contained in issued_capabilities --- audit-trail-move/sources/role_map.move | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/audit-trail-move/sources/role_map.move b/audit-trail-move/sources/role_map.move index fa05a568..07f17eec 100644 --- a/audit-trail-move/sources/role_map.move +++ b/audit-trail-move/sources/role_map.move @@ -517,7 +517,11 @@ public fun rmap_destroy_capability( role_map.security_vault_id == cap_to_destroy.security_vault_id(), ECapabilitySecurityVaultIdMismatch, ); - role_map.issued_capabilities.remove(&cap_to_destroy.id()); + + if (role_map.issued_capabilities.contains(&cap_to_destroy.id())) { + // Capability has not been revoked before destroying, so let's remove it now + role_map.issued_capabilities.remove(&cap_to_destroy.id()); + }; event::emit(CapabilityDestroyed { security_vault_id: role_map.security_vault_id, From a091087cc899170ac86ed7a610487259ea568326 Mon Sep 17 00:00:00 2001 From: Yasir Date: Mon, 12 Jan 2026 12:04:22 +0300 Subject: [PATCH 026/189] chore: function sig refactor & adding of emitting events --- audit-trail-move/Move.lock | 7 +- audit-trail-move/sources/audit_trail.move | 166 +++++++++++----------- audit-trail-move/sources/record.move | 14 ++ 3 files changed, 103 insertions(+), 84 deletions(-) diff --git a/audit-trail-move/Move.lock b/audit-trail-move/Move.lock index b38c76a9..18785fd2 100644 --- a/audit-trail-move/Move.lock +++ b/audit-trail-move/Move.lock @@ -2,7 +2,7 @@ [move] version = 3 -manifest_digest = "205525E3D4D4DF71C1144E3EE5DDD210506D20F1DB2438FC02BB2ADCE7E5BFD6" +manifest_digest = "86C91D3D3A6313FBF00CE187BE48E5E590F256C0805BBA9F9CA2E5E2C7FBFE71" deps_digest = "F9B494B64F0615AED0E98FC12A85B85ECD2BC5185C22D30E7F67786BB52E507C" dependencies = [ { id = "Iota", name = "Iota" }, @@ -40,3 +40,8 @@ dependencies = [ { id = "Iota", name = "Iota" }, { id = "MoveStdlib", name = "MoveStdlib" }, ] + +[move.toolchain-version] +compiler-version = "1.14.0-rc" +edition = "2024.beta" +flavor = "iota" diff --git a/audit-trail-move/sources/audit_trail.move b/audit-trail-move/sources/audit_trail.move index 0a29d60f..440b51f0 100644 --- a/audit-trail-move/sources/audit_trail.move +++ b/audit-trail-move/sources/audit_trail.move @@ -6,7 +6,7 @@ /// An audit trail is a tamper-proof, sequential chain of notarized records where each entry /// references its predecessor, ensuring verifiable continuity and integrity. /// -/// Records are addressed by trail_id + sequence_number +/// Records are addressed by id + sequence_number module audit_trail::main; use audit_trail::{ @@ -38,6 +38,8 @@ const ECapabilityHasBeenRevoked: vector = #[error] const ETrailIdNotCorrect: vector = b"The trail ID associated with the provided capability does not match the audit trail"; +#[error] +const ERecordLocked: vector = b"The record is locked and cannot be deleted"; // ===== Constants ===== const INITIAL_ADMIN_ROLE_NAME: vector = b"Admin"; @@ -45,7 +47,7 @@ const INITIAL_ADMIN_ROLE_NAME: vector = b"Admin"; // ===== Core Structures ===== /// Metadata set at trail creation (immutable) -public struct TrailImmutableMetadata has copy, drop, store { +public struct ImmutableMetadata has copy, drop, store { name: Option, description: Option, } @@ -67,7 +69,7 @@ public struct AuditTrail has key, store { /// A list of role definitions consisting of a unique role specifier and a list of associated permissions roles: VecMap>, /// Set at creation, cannot be changed - immutable_metadata: TrailImmutableMetadata, + immutable_metadata: ImmutableMetadata, /// Can be updated by holders of MetadataUpdate permission updatable_metadata: Option, /// Whitelist of all issued capability IDs @@ -84,7 +86,11 @@ public struct AuditTrailCreated has copy, drop { has_initial_record: bool, } -// TODO: Add event for trail deletion +/// Emitted when the audit trail is deleted +public struct AuditTrailDeleted has copy, drop { + trail_id: ID, + timestamp: u64, +} /// Emitted when a record is added to the trail /// Records are identified by trail_id + sequence_number @@ -95,7 +101,13 @@ public struct RecordAdded has copy, drop { timestamp: u64, } -// TODO: Add event for Record deletion and (if part of MVP) correction +/// Emitted when a record is deleted from the trail +public struct RecordDeleted has copy, drop { + trail_id: ID, + sequence_number: u64, + deleted_by: address, + timestamp: u64, +} /// Emitted when a capability is issued public struct CapabilityIssued has copy, drop { @@ -110,11 +122,8 @@ public struct CapabilityIssued has copy, drop { // ===== Constructors ===== /// Create immutable trail metadata -public fun new_trail_metadata( - name: Option, - description: Option, -): TrailImmutableMetadata { - TrailImmutableMetadata { name, description } +public fun new_metadata(name: Option, description: Option): ImmutableMetadata { + ImmutableMetadata { name, description } } // ===== Trail Creation ===== @@ -140,7 +149,7 @@ public fun create( initial_data: Option, initial_record_metadata: Option, locking_config: LockingConfig, - trail_metadata: TrailImmutableMetadata, + metadata: ImmutableMetadata, updatable_metadata: Option, clock: &Clock, ctx: &mut TxContext, @@ -196,7 +205,7 @@ public fun create( records, locking_config, roles, - immutable_metadata: trail_metadata, + immutable_metadata: metadata, updatable_metadata, issued_capabilities, }; @@ -222,7 +231,7 @@ public fun initial_admin_role_name(): String { /// Add a record to the trail /// /// Records are added sequentially with auto-assigned sequence numbers. -public fun trail_add_record( +public fun add_record( trail: &mut AuditTrail, cap: &Capability, stored_data: D, @@ -256,10 +265,40 @@ public fun trail_add_record( }); } +/// Delete a record from the trail by sequence number +/// +/// The record must not be locked (based on the trail's locking configuration). +/// Requires the DeleteRecord permission. +public fun delete_record( + trail: &mut AuditTrail, + cap: &Capability, + sequence_number: u64, + clock: &Clock, + ctx: &mut TxContext, +) { + assert!(trail.has_capability_permission(cap, &permission::delete_record()), EPermissionDenied); + assert!(linked_table::contains(&trail.records, sequence_number), ERecordNotFound); + assert!(!trail.is_record_locked(sequence_number, clock), ERecordLocked); + + let caller = ctx.sender(); + let timestamp = clock::timestamp_ms(clock); + let trail_id = object::uid_to_inner(&trail.id); + + let record = linked_table::remove(&mut trail.records, sequence_number); + record::destroy(record); + + event::emit(RecordDeleted { + trail_id, + sequence_number, + deleted_by: caller, + timestamp, + }); +} + // ===== Locking ===== /// Check if a record is locked (cannot be deleted) -public fun trail_is_record_locked( +public fun is_record_locked( trail: &AuditTrail, sequence_number: u64, clock: &Clock, @@ -279,7 +318,7 @@ public fun trail_is_record_locked( } /// Update the locking configuration -public fun trail_update_locking_config( +public fun update_locking_config( trail: &mut AuditTrail, cap: &Capability, new_config: LockingConfig, @@ -293,7 +332,7 @@ public fun trail_update_locking_config( } /// Update the `delete_record_lock` locking configuration -public fun trail_update_locking_config_for_delete_record( +public fun update_locking_config_for_delete_record( trail: &mut AuditTrail, cap: &Capability, new_delete_record_lock: LockingWindow, @@ -310,7 +349,7 @@ public fun trail_update_locking_config_for_delete_record( } /// Update the trail's mutable metadata -public fun trail_update_metadata( +public fun update_metadata( trail: &mut AuditTrail, cap: &Capability, new_metadata: Option, @@ -326,78 +365,75 @@ public fun trail_update_metadata( // ===== Trail Query Functions ===== /// Get the total number of records in the trail -public fun trail_record_count(trail: &AuditTrail): u64 { +public fun record_count(trail: &AuditTrail): u64 { trail.record_count } /// Get the trail creator address -public fun trail_creator(trail: &AuditTrail): address { +public fun creator(trail: &AuditTrail): address { trail.creator } /// Get the trail creation timestamp -public fun trail_created_at(trail: &AuditTrail): u64 { +public fun created_at(trail: &AuditTrail): u64 { trail.created_at } /// Get the trail's object ID -public fun trail_id(trail: &AuditTrail): ID { +public fun id(trail: &AuditTrail): ID { object::uid_to_inner(&trail.id) } /// Get the trail name (immutable metadata) -public fun trail_name(trail: &AuditTrail): &Option { +public fun name(trail: &AuditTrail): &Option { &trail.immutable_metadata.name } /// Get the trail description (immutable metadata) -public fun trail_description(trail: &AuditTrail): &Option { +public fun description(trail: &AuditTrail): &Option { &trail.immutable_metadata.description } /// Get the updatable metadata -public fun trail_metadata(trail: &AuditTrail): &Option { +public fun metadata(trail: &AuditTrail): &Option { &trail.updatable_metadata } /// Get the locking configuration -public fun trail_locking_config(trail: &AuditTrail): &LockingConfig { +public fun locking_config(trail: &AuditTrail): &LockingConfig { &trail.locking_config } /// Check if the trail is empty (no records) -public fun trail_is_empty(trail: &AuditTrail): bool { +public fun is_empty(trail: &AuditTrail): bool { linked_table::is_empty(&trail.records) } /// Get the first sequence number (None if empty) -public fun trail_first_sequence(trail: &AuditTrail): Option { +public fun first_sequence(trail: &AuditTrail): Option { *linked_table::front(&trail.records) } /// Get the last sequence number (None if empty) -public fun trail_last_sequence(trail: &AuditTrail): Option { +public fun last_sequence(trail: &AuditTrail): Option { *linked_table::back(&trail.records) } // ===== Record Query Functions ===== /// Get a record by sequence number -public fun trail_get_record( - trail: &AuditTrail, - sequence_number: u64, -): &Record { +public fun get_record(trail: &AuditTrail, sequence_number: u64): &Record { assert!(linked_table::contains(&trail.records, sequence_number), ERecordNotFound); linked_table::borrow(&trail.records, sequence_number) } /// Check if a record exists at the given sequence number -public fun trail_has_record(trail: &AuditTrail, sequence_number: u64): bool { +public fun has_record(trail: &AuditTrail, sequence_number: u64): bool { linked_table::contains(&trail.records, sequence_number) } /// Returns all records of the audit trail -public fun trail_records(trail: &AuditTrail): &LinkedTable> { +public fun records(trail: &AuditTrail): &LinkedTable> { &trail.records } @@ -405,7 +441,7 @@ public fun trail_records(trail: &AuditTrail): &LinkedTable( +public fun get_role_permissions( trail: &AuditTrail, role: &String, ): &VecSet { @@ -414,7 +450,7 @@ public fun trail_get_role_permissions( } /// Create a new role consisting of a role name and associated permissions -public fun trail_create_role( +public fun create_role( trail: &mut AuditTrail, cap: &Capability, role: String, @@ -426,7 +462,7 @@ public fun trail_create_role( } /// Delete an existing role -public fun trail_delete_role( +public fun delete_role( trail: &mut AuditTrail, cap: &Capability, role: &String, @@ -437,7 +473,7 @@ public fun trail_delete_role( } /// Update permissions associated with an existing role -public fun trail_update_role_permissions( +public fun update_role_permissions( trail: &mut AuditTrail, cap: &Capability, role: &String, @@ -450,26 +486,24 @@ public fun trail_update_role_permissions( } /// Returns the roles defined in the audit trail -public fun trail_roles( - trail: &AuditTrail, -): &VecMap> { +public fun roles(trail: &AuditTrail): &VecMap> { &trail.roles } /// Indicates if the specified role exists in the audit trail -public fun trail_has_role(trail: &AuditTrail, role: &String): bool { +public fun has_role(trail: &AuditTrail, role: &String): bool { vec_map::contains(&trail.roles, role) } // ===== Capability related Functions ===== /// Indicates if a provided capability has a specific permission. -public fun trail_has_capability_permission( +public fun has_capability_permission( trail: &AuditTrail, cap: &Capability, permission: &Permission, ): bool { - assert!(trail.id() == cap.trail_id(), ETrailIdNotCorrect); + assert!(trail.id() == cap.id(), ETrailIdNotCorrect); assert!(trail.issued_capabilities.contains(&cap.id()), ECapabilityHasBeenRevoked); let permissions = trail.get_role_permissions(cap.role()); vec_set::contains(permissions, permission) @@ -477,7 +511,7 @@ public fun trail_has_capability_permission( /// Create a new capability with a specific role /// Aborts with ERoleDoesNotExist if the role does not exist. -public fun trail_new_capability( +public fun new_capability( trail: &mut AuditTrail, cap: &Capability, role: &String, @@ -502,16 +536,16 @@ public fun trail_new_capability( /// TODO: Clarify if we need to restrict access with the `CapabilitiesRevoke` permission here. /// If yes, we also need a destroy function for Admin capabilities (without the need of another Admin capability). /// Otherwise the last Admin capability holder will block the trail forever by not being able to destroy it. -public fun trail_destroy_capability( +public fun destroy_capability( trail: &mut AuditTrail, cap_to_destroy: Capability, ) { - assert!(trail.id() == cap_to_destroy.trail_id(), ETrailIdNotCorrect); + assert!(trail.id() == cap_to_destroy.id(), ETrailIdNotCorrect); trail.issued_capabilities.remove(&cap_to_destroy.id()); cap_to_destroy.destroy(); } -public fun trail_revoke_capability( +public fun revoke_capability( trail: &mut AuditTrail, cap: &Capability, cap_to_revoke: ID, @@ -523,40 +557,6 @@ public fun trail_revoke_capability( trail.issued_capabilities.remove(&cap_to_revoke); } -public fun trail_issued_capabilities(trail: &AuditTrail): &VecSet { +public fun issued_capabilities(trail: &AuditTrail): &VecSet { &trail.issued_capabilities } - -// ===== public use statements ===== - -public use fun trail_id as AuditTrail.id; -public use fun trail_creator as AuditTrail.creator; -public use fun trail_created_at as AuditTrail.created_at; -public use fun trail_add_record as AuditTrail.add_record; -public use fun trail_record_count as AuditTrail.record_count; -public use fun trail_records as AuditTrail.records; -public use fun trail_name as AuditTrail.name; -public use fun trail_description as AuditTrail.description; -public use fun trail_metadata as AuditTrail.metadata; -public use fun trail_locking_config as AuditTrail.locking_config; -public use fun trail_update_locking_config as AuditTrail.update_locking_config; -public use fun trail_is_record_locked as AuditTrail.is_record_locked; -public use fun trail_update_locking_config_for_delete_record as - AuditTrail.update_locking_config_for_delete_record; -public use fun trail_update_metadata as AuditTrail.update_metadata; -public use fun trail_is_empty as AuditTrail.is_empty; -public use fun trail_first_sequence as AuditTrail.first_sequence; -public use fun trail_last_sequence as AuditTrail.last_sequence; -public use fun trail_get_record as AuditTrail.get_record; -public use fun trail_has_record as AuditTrail.has_record; -public use fun trail_has_capability_permission as AuditTrail.has_capability_permission; -public use fun trail_new_capability as AuditTrail.new_capability; -public use fun trail_destroy_capability as AuditTrail.destroy_capability; -public use fun trail_revoke_capability as AuditTrail.revoke_capability; -public use fun trail_issued_capabilities as AuditTrail.issued_capabilities; -public use fun trail_get_role_permissions as AuditTrail.get_role_permissions; -public use fun trail_create_role as AuditTrail.create_role; -public use fun trail_delete_role as AuditTrail.delete_role; -public use fun trail_update_role_permissions as AuditTrail.update_role_permissions; -public use fun trail_roles as AuditTrail.roles; -public use fun trail_has_role as AuditTrail.has_role; diff --git a/audit-trail-move/sources/record.move b/audit-trail-move/sources/record.move index 06956ccc..c384f101 100644 --- a/audit-trail-move/sources/record.move +++ b/audit-trail-move/sources/record.move @@ -68,3 +68,17 @@ public fun added_by(record: &Record): address { public fun added_at(record: &Record): u64 { record.added_at } + +// ===== Destructors ===== + +/// Destroy a record (package-private, called by audit_trail module when deleting) +/// Note: D must have `drop` ability to allow deletion +public(package) fun destroy(record: Record) { + let Record { + stored_data: _, + record_metadata: _, + sequence_number: _, + added_by: _, + added_at: _, + } = record; +} From 06d26b9a45272cf6588d5e8fd75089481dd32492 Mon Sep 17 00:00:00 2001 From: Yasir Date: Mon, 12 Jan 2026 19:08:36 +0300 Subject: [PATCH 027/189] refactor: update capability permission checks and enhance test coverage - Changed capability permission checks in `has_capability_permission` and `destroy_capability` functions to use `cap.trail_id()` instead of `cap.id()`. - Improved test cases for capability creation, revocation, and metadata updates, ensuring proper permission handling and error scenarios. - Added new tests for locking configurations and metadata management, enhancing overall test coverage for the audit trail functionality. --- audit-trail-move/sources/audit_trail.move | 4 +- audit-trail-move/tests/capability_tests.move | 383 +++++---- .../tests/create_audit_trail_tests.move | 54 +- audit-trail-move/tests/locking_tests.move | 444 +++++++++++ audit-trail-move/tests/metadata_tests.move | 276 +++++++ audit-trail-move/tests/record_tests.move | 733 ++++++++++++++++++ audit-trail-move/tests/role_tests.move | 241 +++++- audit-trail-move/tests/test_utils.move | 10 +- 8 files changed, 1891 insertions(+), 254 deletions(-) create mode 100644 audit-trail-move/tests/locking_tests.move create mode 100644 audit-trail-move/tests/metadata_tests.move create mode 100644 audit-trail-move/tests/record_tests.move diff --git a/audit-trail-move/sources/audit_trail.move b/audit-trail-move/sources/audit_trail.move index 440b51f0..1e047463 100644 --- a/audit-trail-move/sources/audit_trail.move +++ b/audit-trail-move/sources/audit_trail.move @@ -503,7 +503,7 @@ public fun has_capability_permission( cap: &Capability, permission: &Permission, ): bool { - assert!(trail.id() == cap.id(), ETrailIdNotCorrect); + assert!(trail.id() == cap.trail_id(), ETrailIdNotCorrect); assert!(trail.issued_capabilities.contains(&cap.id()), ECapabilityHasBeenRevoked); let permissions = trail.get_role_permissions(cap.role()); vec_set::contains(permissions, permission) @@ -540,7 +540,7 @@ public fun destroy_capability( trail: &mut AuditTrail, cap_to_destroy: Capability, ) { - assert!(trail.id() == cap_to_destroy.id(), ETrailIdNotCorrect); + assert!(trail.id() == cap_to_destroy.trail_id(), ETrailIdNotCorrect); trail.issued_capabilities.remove(&cap_to_destroy.id()); cap_to_destroy.destroy(); } diff --git a/audit-trail-move/tests/capability_tests.move b/audit-trail-move/tests/capability_tests.move index 8fc81add..93f8ef16 100644 --- a/audit-trail-move/tests/capability_tests.move +++ b/audit-trail-move/tests/capability_tests.move @@ -4,20 +4,13 @@ module audit_trail::capability_tests; use audit_trail::{ capability::Capability, locking, - main::AuditTrail, + main::{Self, AuditTrail}, permission, test_utils::{Self, TestData, setup_test_audit_trail} }; use iota::test_scenario as ts; use std::string; -/// Test that new_capability() correctly creates a capability and tracks it in issued_capabilities. -/// -/// This test validates: -/// - Capability is created with correct role and trail ID -/// - Capability ID is added to the audit trail's issued_capabilities set -/// - Multiple capabilities can be issued and all are tracked -/// - Each capability has a unique ID #[test] fun test_new_capability() { let admin_user = @0xAD; @@ -26,31 +19,27 @@ fun test_new_capability() { let mut scenario = ts::begin(admin_user); - // Setup: Create audit trail with admin capability let trail_id = { let locking_config = locking::new(locking::window_count_based(0)); - let (admin_cap, trail_id) = setup_test_audit_trail( &mut scenario, locking_config, std::option::none(), ); - transfer::public_transfer(admin_cap, admin_user); trail_id }; - // Create a custom role for testing + // Create a role to issue capabilities for ts::next_tx(&mut scenario, admin_user); { let admin_cap = ts::take_from_sender(&scenario); let mut trail = ts::take_shared>(&scenario); - let record_admin_perms = permission::record_admin_permissions(); trail.create_role( &admin_cap, string::utf8(b"RecordAdmin"), - record_admin_perms, + permission::record_admin_permissions(), ts::ctx(&mut scenario), ); @@ -58,13 +47,12 @@ fun test_new_capability() { ts::return_shared(trail); }; - // Test: Issue first capability + // Issue first capability and verify it's tracked ts::next_tx(&mut scenario, admin_user); let cap1_id = { let admin_cap = ts::take_from_sender(&scenario); let mut trail = ts::take_shared>(&scenario); - // Verify initial state - only admin capability should be tracked let initial_cap_count = trail.issued_capabilities().size(); assert!(initial_cap_count == 1, 0); // Only admin cap @@ -74,13 +62,10 @@ fun test_new_capability() { ts::ctx(&mut scenario), ); - // Verify capability was created correctly assert!(cap1.role() == string::utf8(b"RecordAdmin"), 1); assert!(cap1.trail_id() == trail_id, 2); let cap1_id = object::id(&cap1); - - // Verify capability ID is tracked in issued_capabilities assert!(trail.issued_capabilities().size() == initial_cap_count + 1, 3); assert!(trail.issued_capabilities().contains(&cap1_id), 4); @@ -91,9 +76,9 @@ fun test_new_capability() { cap1_id }; - // Test: Issue second capability + // Issue second capability and verify both are tracked with unique IDs ts::next_tx(&mut scenario, admin_user); - let _cap2_id = { + { let admin_cap = ts::take_from_sender(&scenario); let mut trail = ts::take_shared>(&scenario); @@ -107,31 +92,19 @@ fun test_new_capability() { let cap2_id = object::id(&cap2); - // Verify both capabilities are tracked assert!(trail.issued_capabilities().size() == previous_cap_count + 1, 5); assert!(trail.issued_capabilities().contains(&cap1_id), 6); assert!(trail.issued_capabilities().contains(&cap2_id), 7); - - // Verify capabilities have unique IDs assert!(cap1_id != cap2_id, 8); transfer::public_transfer(cap2, user2); ts::return_to_sender(&scenario, admin_cap); ts::return_shared(trail); - - cap2_id }; ts::end(scenario); } -/// Test that revoke_capability() correctly revokes a capability and removes it from issued_capabilities. -/// -/// This test validates: -/// - Capability can be revoked by an authorized user -/// - Revoked capability ID is removed from issued_capabilities set -/// - Revoking one capability doesn't affect other capabilities -/// - Revoked capability object is properly destroyed #[test] fun test_revoke_capability() { let admin_user = @0xAD; @@ -140,31 +113,26 @@ fun test_revoke_capability() { let mut scenario = ts::begin(admin_user); - // Setup: Create audit trail with admin capability - let _trail_id = { + { let locking_config = locking::new(locking::window_count_based(0)); - - let (admin_cap, trail_id) = setup_test_audit_trail( + let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, std::option::none(), ); - transfer::public_transfer(admin_cap, admin_user); - trail_id }; - // Create a custom role for testing + // Create role ts::next_tx(&mut scenario, admin_user); { let admin_cap = ts::take_from_sender(&scenario); let mut trail = ts::take_shared>(&scenario); - let record_admin_perms = permission::record_admin_permissions(); trail.create_role( &admin_cap, string::utf8(b"RecordAdmin"), - record_admin_perms, + permission::record_admin_permissions(), ts::ctx(&mut scenario), ); @@ -200,29 +168,21 @@ fun test_revoke_capability() { (cap1_id, cap2_id) }; - // Test: Revoke first capability + // Revoke first capability and verify it's removed from tracking ts::next_tx(&mut scenario, user1); { let admin_cap = ts::take_from_address(&scenario, admin_user); let mut trail = ts::take_shared>(&scenario); let cap1 = ts::take_from_sender(&scenario); - // Verify both capabilities are tracked before revocation let cap_count_before = trail.issued_capabilities().size(); assert!(trail.issued_capabilities().contains(&cap1_id), 0); assert!(trail.issued_capabilities().contains(&cap2_id), 1); - // Revoke the capability - trail.revoke_capability( - &admin_cap, - cap1.id(), - ); + trail.revoke_capability(&admin_cap, cap1.id()); - // Verify capability was removed from tracking assert!(trail.issued_capabilities().size() == cap_count_before - 1, 2); assert!(!trail.issued_capabilities().contains(&cap1_id), 3); - - // Verify other capability is still tracked assert!(trail.issued_capabilities().contains(&cap2_id), 4); ts::return_to_address(admin_user, admin_cap); @@ -230,46 +190,15 @@ fun test_revoke_capability() { ts::return_shared(trail); }; - // Verify cap1 is still available to user1 -it has been revoked, not destroyed + // Verify revoked capability object still exists (just invalidated) ts::next_tx(&mut scenario, user1); { - // This should not find cap1 since it was revoked assert!(ts::has_most_recent_for_sender(&scenario), 5); }; - // Test: Revoke second capability - ts::next_tx(&mut scenario, user2); - { - let admin_cap = ts::take_from_address(&scenario, admin_user); - let mut trail = ts::take_shared>(&scenario); - let cap2 = ts::take_from_sender(&scenario); - - let cap_count_before = trail.issued_capabilities().size(); - - trail.revoke_capability( - &admin_cap, - cap2.id(), - ); - - // Verify capability was removed from tracking - assert!(trail.issued_capabilities().size() == cap_count_before - 1, 6); - assert!(!trail.issued_capabilities().contains(&cap2_id), 7); - - ts::return_to_address(admin_user, admin_cap); - ts::return_to_sender(&scenario, cap2); - ts::return_shared(trail); - }; - ts::end(scenario); } -/// Test that destroy_capability() correctly destroys a capability and removes it from issued_capabilities. -/// -/// This test validates: -/// - Capability owner can destroy their own capability -/// - Destroyed capability ID is removed from issued_capabilities set -/// - Destroying one capability doesn't affect other capabilities -/// - Capability object is properly destroyed and cannot be used again #[test] fun test_destroy_capability() { let admin_user = @0xAD; @@ -278,31 +207,26 @@ fun test_destroy_capability() { let mut scenario = ts::begin(admin_user); - // Setup: Create audit trail with admin capability - let trail_id = { + { let locking_config = locking::new(locking::window_count_based(0)); - - let (admin_cap, trail_id) = setup_test_audit_trail( + let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, std::option::none(), ); - transfer::public_transfer(admin_cap, admin_user); - trail_id }; - // Create a custom role for testing + // Create role ts::next_tx(&mut scenario, admin_user); { let admin_cap = ts::take_from_sender(&scenario); let mut trail = ts::take_shared>(&scenario); - let record_admin_perms = permission::record_admin_permissions(); trail.create_role( &admin_cap, string::utf8(b"RecordAdmin"), - record_admin_perms, + permission::record_admin_permissions(), ts::ctx(&mut scenario), ); @@ -338,213 +262,282 @@ fun test_destroy_capability() { (cap1_id, cap2_id) }; - // Test: User1 destroys their own capability + // User1 destroys their capability ts::next_tx(&mut scenario, user1); { let mut trail = ts::take_shared>(&scenario); let cap1 = ts::take_from_sender(&scenario); - // Verify both capabilities are tracked before destruction let cap_count_before = trail.issued_capabilities().size(); assert!(trail.issued_capabilities().contains(&cap1_id), 0); assert!(trail.issued_capabilities().contains(&cap2_id), 1); - // Destroy the capability trail.destroy_capability(cap1); - // Verify capability was removed from tracking assert!(trail.issued_capabilities().size() == cap_count_before - 1, 2); assert!(!trail.issued_capabilities().contains(&cap1_id), 3); - - // Verify other capability is still tracked assert!(trail.issued_capabilities().contains(&cap2_id), 4); ts::return_shared(trail); }; - // Verify cap1 is no longer available to user1 + // Verify destroyed capability no longer exists ts::next_tx(&mut scenario, user1); { - // This should not find cap1 since it was destroyed assert!(!ts::has_most_recent_for_sender(&scenario), 5); }; - // Test: User2 destroys their own capability - ts::next_tx(&mut scenario, user2); + ts::end(scenario); +} + +// ===== Error Case Tests ===== + +#[test] +#[expected_failure(abort_code = main::ECapabilityHasBeenRevoked)] +fun test_revoked_capability_cannot_be_used() { + let admin_user = @0xAD; + let user = @0xB0B; + + let mut scenario = ts::begin(admin_user); + + { + let locking_config = locking::new(locking::window_count_based(0)); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::none(), + ); + transfer::public_transfer(admin_cap, admin_user); + }; + + // Create role and issue capability to user + ts::next_tx(&mut scenario, admin_user); { + let admin_cap = ts::take_from_sender(&scenario); let mut trail = ts::take_shared>(&scenario); - let cap2 = ts::take_from_sender(&scenario); - - let cap_count_before = trail.issued_capabilities().size(); - trail.destroy_capability(cap2); + trail.create_role( + &admin_cap, + string::utf8(b"RecordAdmin"), + permission::record_admin_permissions(), + ts::ctx(&mut scenario), + ); - // Verify capability was removed from tracking - assert!(trail.issued_capabilities().size() == cap_count_before - 1, 6); - assert!(!trail.issued_capabilities().contains(&cap2_id), 7); + let user_cap = trail.new_capability( + &admin_cap, + &string::utf8(b"RecordAdmin"), + ts::ctx(&mut scenario), + ); + transfer::public_transfer(user_cap, user); + ts::return_to_sender(&scenario, admin_cap); ts::return_shared(trail); }; - // Verify only admin capability remains + // Revoke the capability ts::next_tx(&mut scenario, admin_user); { - let trail = ts::take_shared>(&scenario); + let admin_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + let user_cap = ts::take_from_address(&scenario, user); + + trail.revoke_capability(&admin_cap, user_cap.id()); + + ts::return_to_address(user, user_cap); + ts::return_to_sender(&scenario, admin_cap); + ts::return_shared(trail); + }; + + // Try to use revoked capability - should fail + ts::next_tx(&mut scenario, user); + { + let mut trail = ts::take_shared>(&scenario); + let user_cap = ts::take_from_sender(&scenario); - // Only the initial admin capability should remain - assert!(trail.issued_capabilities().size() == 1, 8); + let mut clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); + clock.set_for_testing(test_utils::initial_time_for_testing() + 1000); + trail.add_record( + &user_cap, + test_utils::new_test_data(1, b"Should fail"), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + iota::clock::destroy_for_testing(clock); + ts::return_to_sender(&scenario, user_cap); ts::return_shared(trail); }; ts::end(scenario); } -/// Test capability lifecycle: creation, usage, and destruction in a complete workflow. -/// -/// This test validates: -/// - Multiple capabilities can be created for different roles -/// - Capabilities can be used to perform authorized actions -/// - Capabilities can be revoked or destroyed -/// - issued_capabilities tracking remains accurate throughout the lifecycle #[test] -fun test_capability_lifecycle() { +#[expected_failure(abort_code = main::ERoleDoesNotExist)] +fun test_new_capability_for_nonexistent_role() { let admin_user = @0xAD; - let record_admin_user = @0xB0B; - let role_admin_user = @0xCAB; let mut scenario = ts::begin(admin_user); - // Setup: Create audit trail - let trail_id = { + { let locking_config = locking::new(locking::window_count_based(0)); - - let (admin_cap, trail_id) = setup_test_audit_trail( + let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, std::option::none(), ); - transfer::public_transfer(admin_cap, admin_user); - trail_id }; - // Create roles ts::next_tx(&mut scenario, admin_user); { let admin_cap = ts::take_from_sender(&scenario); let mut trail = ts::take_shared>(&scenario); - // Initially only admin cap should be tracked - assert!(trail.issued_capabilities().size() == 1, 0); - - trail.create_role( + let bad_cap = trail.new_capability( &admin_cap, - string::utf8(b"RecordAdmin"), - permission::record_admin_permissions(), - ts::ctx(&mut scenario), - ); - - trail.create_role( - &admin_cap, - string::utf8(b"RoleAdmin"), - permission::role_admin_permissions(), + &string::utf8(b"NonExistentRole"), ts::ctx(&mut scenario), ); + bad_cap.destroy_for_testing(); ts::return_to_sender(&scenario, admin_cap); ts::return_shared(trail); }; - // Issue capabilities + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = main::EPermissionDenied)] +fun test_revoke_capability_permission_denied() { + let admin_user = @0xAD; + let user1 = @0xB0B; + let user2 = @0xCAB; + + let mut scenario = ts::begin(admin_user); + + { + let locking_config = locking::new(locking::window_count_based(0)); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::none(), + ); + transfer::public_transfer(admin_cap, admin_user); + }; + + // Create two roles: one without revoke permission, one with record permissions ts::next_tx(&mut scenario, admin_user); - let (record_cap_id, role_cap_id) = { + { let admin_cap = ts::take_from_sender(&scenario); let mut trail = ts::take_shared>(&scenario); - let record_cap = trail.new_capability( + let perms = permission::from_vec(vector[permission::add_record()]); + trail.create_role(&admin_cap, string::utf8(b"NoRevokePerm"), perms, ts::ctx(&mut scenario)); + + trail.create_role( &admin_cap, - &string::utf8(b"RecordAdmin"), + string::utf8(b"RecordAdmin"), + permission::record_admin_permissions(), ts::ctx(&mut scenario), ); - let record_cap_id = object::id(&record_cap); - transfer::public_transfer(record_cap, record_admin_user); - let role_cap = trail.new_capability( + let user1_cap = trail.new_capability( &admin_cap, - &string::utf8(b"RoleAdmin"), + &string::utf8(b"NoRevokePerm"), ts::ctx(&mut scenario), ); - let role_cap_id = object::id(&role_cap); - transfer::public_transfer(role_cap, role_admin_user); - // Verify all capabilities are tracked - assert!(trail.issued_capabilities().size() == 3, 1); // admin + record + role - assert!(trail.issued_capabilities().contains(&record_cap_id), 2); - assert!(trail.issued_capabilities().contains(&role_cap_id), 3); + let user2_cap = trail.new_capability( + &admin_cap, + &string::utf8(b"RecordAdmin"), + ts::ctx(&mut scenario), + ); + transfer::public_transfer(user1_cap, user1); + transfer::public_transfer(user2_cap, user2); ts::return_to_sender(&scenario, admin_cap); ts::return_shared(trail); - - (record_cap_id, role_cap_id) }; - // Use RecordAdmin capability to add a record - ts::next_tx(&mut scenario, record_admin_user); + // User1 (without revoke permission) tries to revoke User2's capability + ts::next_tx(&mut scenario, user1); { + let user1_cap = ts::take_from_sender(&scenario); let mut trail = ts::take_shared>(&scenario); - let record_cap = ts::take_from_sender(&scenario); + let user2_cap = ts::take_from_address(&scenario, user2); - let mut clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); - clock.set_for_testing(test_utils::initial_time_for_testing() + 1000); + trail.revoke_capability(&user1_cap, user2_cap.id()); - let test_data = test_utils::new_test_data(1, b"Test record"); - trail.add_record( - &record_cap, - test_data, + ts::return_to_address(user2, user2_cap); + ts::return_to_sender(&scenario, user1_cap); + ts::return_shared(trail); + }; + + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = main::EPermissionDenied)] +fun test_new_capability_permission_denied() { + let admin_user = @0xAD; + let user = @0xB0B; + + let mut scenario = ts::begin(admin_user); + + { + let locking_config = locking::new(locking::window_count_based(0)); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, std::option::none(), - &clock, - ts::ctx(&mut scenario), ); - - iota::clock::destroy_for_testing(clock); - ts::return_to_sender(&scenario, record_cap); - ts::return_shared(trail); + transfer::public_transfer(admin_cap, admin_user); }; - // RecordAdmin destroys their capability - ts::next_tx(&mut scenario, record_admin_user); + // Create role without add_capabilities permission + ts::next_tx(&mut scenario, admin_user); { + let admin_cap = ts::take_from_sender(&scenario); let mut trail = ts::take_shared>(&scenario); - let record_cap = ts::take_from_sender(&scenario); - trail.destroy_capability(record_cap); + let perms = permission::from_vec(vector[permission::add_record()]); + trail.create_role(&admin_cap, string::utf8(b"NoCapPerm"), perms, ts::ctx(&mut scenario)); + + trail.create_role( + &admin_cap, + string::utf8(b"RecordAdmin"), + permission::record_admin_permissions(), + ts::ctx(&mut scenario), + ); - // Verify capability was removed - assert!(trail.issued_capabilities().size() == 2, 4); // admin + role - assert!(!trail.issued_capabilities().contains(&record_cap_id), 5); + let user_cap = trail.new_capability( + &admin_cap, + &string::utf8(b"NoCapPerm"), + ts::ctx(&mut scenario), + ); + transfer::public_transfer(user_cap, user); + ts::return_to_sender(&scenario, admin_cap); ts::return_shared(trail); }; - // Admin revokes RoleAdmin capability - ts::next_tx(&mut scenario, role_admin_user); + // User tries to issue a new capability without permission + ts::next_tx(&mut scenario, user); { - let admin_cap = ts::take_from_address(&scenario, admin_user); + let user_cap = ts::take_from_sender(&scenario); let mut trail = ts::take_shared>(&scenario); - let role_cap = ts::take_from_sender(&scenario); - trail.revoke_capability( - &admin_cap, - role_cap.id(), + let new_cap = trail.new_capability( + &user_cap, + &string::utf8(b"RecordAdmin"), + ts::ctx(&mut scenario), ); - // Verify capability was removed - assert!(trail.issued_capabilities().size() == 1, 6); // only admin remains - assert!(!trail.issued_capabilities().contains(&role_cap_id), 7); - - ts::return_to_address(admin_user, admin_cap); - ts::return_to_sender(&scenario, role_cap); + new_cap.destroy_for_testing(); + ts::return_to_sender(&scenario, user_cap); ts::return_shared(trail); }; diff --git a/audit-trail-move/tests/create_audit_trail_tests.move b/audit-trail-move/tests/create_audit_trail_tests.move index 6ced54ad..688c06bc 100644 --- a/audit-trail-move/tests/create_audit_trail_tests.move +++ b/audit-trail-move/tests/create_audit_trail_tests.move @@ -1,5 +1,4 @@ #[test_only] -/// This module contains comprehensive tests for the AuditTrail creation functionality. module audit_trail::create_audit_trail_tests; use audit_trail::{ @@ -11,10 +10,6 @@ use audit_trail::{ use iota::{clock, test_scenario as ts}; use std::string; -/// Goals of this test: -/// - Verifies creating an AuditTrail with no initial record -/// - Checks admin capability creation with correct role and trail_id -/// - Validates trail metadata (creator, creation time, record count) #[test] fun test_create_without_initial_record() { let user = @0xA; @@ -42,9 +37,9 @@ fun test_create_without_initial_record() { let trail = ts::take_shared>(&scenario); // Verify trail was created correctly - assert!(trail.trail_creator() == user, 2); - assert!(trail.trail_created_at() == initial_time_for_testing(), 3); - assert!(trail.trail_record_count() == 0, 4); + assert!(trail.creator() == user, 2); + assert!(trail.created_at() == initial_time_for_testing(), 3); + assert!(trail.record_count() == 0, 4); ts::return_shared(trail); }; @@ -52,10 +47,6 @@ fun test_create_without_initial_record() { ts::end(scenario); } -/// Goals of this test: -/// - Tests AuditTrail creation with an initial record -/// - Verifies the trail contains exactly one record after creation -/// - Validates the initial record exists at index 0 #[test] fun test_create_with_initial_record() { let user = @0xB; @@ -84,12 +75,12 @@ fun test_create_with_initial_record() { let trail = ts::take_shared>(&scenario); // Verify trail with initial record - assert!(trail.trail_creator() == user, 2); - assert!(trail.trail_created_at() == initial_time_for_testing(), 3); - assert!(trail.trail_record_count() == 1, 4); + assert!(trail.creator() == user, 2); + assert!(trail.created_at() == initial_time_for_testing(), 3); + assert!(trail.record_count() == 1, 4); // Verify the initial record exists - assert!(trail.trail_has_record(0), 5); + assert!(trail.has_record(0), 5); ts::return_shared(trail); }; @@ -97,10 +88,6 @@ fun test_create_with_initial_record() { ts::end(scenario); } -/// Goals of this test: -/// - Tests creating a trail with minimal metadata (optional fields set to none) -/// - Uses a custom clock time to verify timestamp handling -/// - Ensures the system handles minimal configuration correctly #[test] fun test_create_minimal_metadata() { let user = @0xC; @@ -111,7 +98,7 @@ fun test_create_minimal_metadata() { clock.set_for_testing(3000); let locking_config = locking::new(locking::window_count_based(0)); - let trail_metadata = main::new_trail_metadata( + let trail_metadata = main::new_metadata( std::option::none(), std::option::none(), ); @@ -139,9 +126,9 @@ fun test_create_minimal_metadata() { let trail = ts::take_shared>(&scenario); // Verify trail was created - assert!(trail.trail_creator() == user, 1); - assert!(trail.trail_created_at() == 3000, 2); - assert!(trail.trail_record_count() == 0, 3); + assert!(trail.creator() == user, 1); + assert!(trail.created_at() == 3000, 2); + assert!(trail.record_count() == 0, 3); ts::return_shared(trail); }; @@ -149,10 +136,6 @@ fun test_create_minimal_metadata() { ts::end(scenario); } -/// Goals of this test: -/// - Verifies AuditTrail creation with locking configuration enabled -/// - Tests a 7-day time-based lock period -/// - Validates the trail is created successfully with locking constraints #[test] fun test_create_with_locking_enabled() { let user = @0xD; @@ -175,8 +158,8 @@ fun test_create_with_locking_enabled() { let trail = ts::take_shared>(&scenario); // Verify trail with locking enabled - assert!(trail.trail_creator() == user, 0); - assert!(trail.trail_record_count() == 0, 1); + assert!(trail.creator() == user, 0); + assert!(trail.record_count() == 0, 1); ts::return_shared(trail); }; @@ -184,10 +167,6 @@ fun test_create_with_locking_enabled() { ts::end(scenario); } -/// Goals of this test: -/// - Tests creating multiple independent AuditTrail instances -/// - Verifies each trail receives a unique ID -/// - Ensures multiple trails can coexist without conflicts #[test] fun test_create_multiple_trails() { let user = @0xE; @@ -230,13 +209,6 @@ fun test_create_multiple_trails() { ts::end(scenario); } -/// Test creating a MetadataAdmin role with metadata_admin_permissions. -/// -/// This test verifies that: -/// 1. A creator can create an AuditTrail and receive an admin capability -/// 2. The admin capability can be transferred to another user -/// 3. The user can use the capability to create a new MetadataAdmin role -/// 4. The new role has the correct permissions (meta_data_update and meta_data_delete) #[test] fun test_create_metadata_admin_role() { let creator = @0xA; diff --git a/audit-trail-move/tests/locking_tests.move b/audit-trail-move/tests/locking_tests.move new file mode 100644 index 00000000..fea157b4 --- /dev/null +++ b/audit-trail-move/tests/locking_tests.move @@ -0,0 +1,444 @@ +#[test_only] +module audit_trail::locking_tests; + +use audit_trail::{ + capability::Capability, + locking, + main::{Self, AuditTrail}, + permission, + test_utils::{TestData, setup_test_audit_trail, new_test_data, initial_time_for_testing} +}; +use iota::{clock, test_scenario as ts}; +use std::string; + +// ===== Time-Based Locking Tests ===== + +#[test] +fun test_time_based_locking_within_window() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + // Create trail with 1 hour time-based locking + { + let locking_config = locking::time_based(3600); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::some(new_test_data(1, b"Test")), + ); + admin_cap.destroy_for_testing(); + }; + + ts::next_tx(&mut scenario, admin); + { + let trail = ts::take_shared>(&scenario); + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + + // 1 second after creation - locked + clock.set_for_testing(initial_time_for_testing() + 1000); + assert!(trail.is_record_locked(0, &clock), 0); + + // 30 minutes after - locked + clock.set_for_testing(initial_time_for_testing() + 1800 * 1000); + assert!(trail.is_record_locked(0, &clock), 1); + + // 59 minutes after - locked + clock.set_for_testing(initial_time_for_testing() + 3540 * 1000); + assert!(trail.is_record_locked(0, &clock), 2); + + clock::destroy_for_testing(clock); + ts::return_shared(trail); + }; + + ts::end(scenario); +} + +#[test] +fun test_time_based_locking_outside_window() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + // Create trail with 1 hour time-based locking + { + let locking_config = locking::time_based(3600); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::some(new_test_data(1, b"Test")), + ); + admin_cap.destroy_for_testing(); + }; + + ts::next_tx(&mut scenario, admin); + { + let trail = ts::take_shared>(&scenario); + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + + // 1 hour + 1 second after creation - unlocked + clock.set_for_testing(initial_time_for_testing() + 3601 * 1000); + assert!(!trail.is_record_locked(0, &clock), 0); + + // 2 hours after - unlocked + clock.set_for_testing(initial_time_for_testing() + 7200 * 1000); + assert!(!trail.is_record_locked(0, &clock), 1); + + clock::destroy_for_testing(clock); + ts::return_shared(trail); + }; + + ts::end(scenario); +} + +// ===== Count-Based Locking Tests ===== + +#[test] +fun test_count_based_locking() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + // Create trail with count-based locking (last 2 locked) + { + let locking_config = locking::count_based(2); + let (admin_cap, _) = setup_test_audit_trail(&mut scenario, locking_config, std::option::none()); + transfer::public_transfer(admin_cap, admin); + }; + + // Create RecordAdmin role and capability + ts::next_tx(&mut scenario, admin); + { + let admin_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + + trail.create_role( + &admin_cap, + string::utf8(b"RecordAdmin"), + permission::record_admin_permissions(), + ts::ctx(&mut scenario), + ); + + let record_cap = trail.new_capability( + &admin_cap, + &string::utf8(b"RecordAdmin"), + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(record_cap, admin); + admin_cap.destroy_for_testing(); + ts::return_shared(trail); + }; + + // Add 5 records and verify locking + ts::next_tx(&mut scenario, admin); + { + let record_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + clock.set_for_testing(initial_time_for_testing() + 1000); + + let mut i = 0u64; + while (i < 5) { + trail.add_record(&record_cap, new_test_data(i, b"Record"), std::option::none(), &clock, ts::ctx(&mut scenario)); + i = i + 1; + }; + + // With 5 records and last 2 locked: + // Records 0, 1, 2 = unlocked (have 4, 3, 2 records after them) + // Records 3, 4 = locked (have 1, 0 records after them) + assert!(!trail.is_record_locked(0, &clock), 0); + assert!(!trail.is_record_locked(1, &clock), 1); + assert!(!trail.is_record_locked(2, &clock), 2); + assert!(trail.is_record_locked(3, &clock), 3); + assert!(trail.is_record_locked(4, &clock), 4); + + clock::destroy_for_testing(clock); + record_cap.destroy_for_testing(); + ts::return_shared(trail); + }; + + ts::end(scenario); +} + +#[test] +fun test_count_based_locking_single_record() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + // Create trail with "last 3 locked" - single record should be locked + { + let locking_config = locking::count_based(3); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::some(new_test_data(1, b"Single")), + ); + admin_cap.destroy_for_testing(); + }; + + ts::next_tx(&mut scenario, admin); + { + let trail = ts::take_shared>(&scenario); + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + clock.set_for_testing(initial_time_for_testing() + 1000); + + assert!(trail.is_record_locked(0, &clock), 0); + + clock::destroy_for_testing(clock); + ts::return_shared(trail); + }; + + ts::end(scenario); +} + +// ===== No Locking Tests ===== + +#[test] +fun test_no_locking() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + { + let locking_config = locking::none(); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::some(new_test_data(1, b"Test")), + ); + admin_cap.destroy_for_testing(); + }; + + ts::next_tx(&mut scenario, admin); + { + let trail = ts::take_shared>(&scenario); + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + clock.set_for_testing(initial_time_for_testing()); + + // No locking config = never locked + assert!(!trail.is_record_locked(0, &clock), 0); + + clock::destroy_for_testing(clock); + ts::return_shared(trail); + }; + + ts::end(scenario); +} + +// ===== Update Locking Config Tests ===== + +#[test] +fun test_update_locking_config() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + // Create trail with no locking + { + let locking_config = locking::none(); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::some(new_test_data(1, b"Test")), + ); + transfer::public_transfer(admin_cap, admin); + }; + + // Create LockingAdmin role + ts::next_tx(&mut scenario, admin); + { + let admin_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + + let perms = permission::from_vec(vector[permission::update_locking_config()]); + trail.create_role(&admin_cap, string::utf8(b"LockingAdmin"), perms, ts::ctx(&mut scenario)); + + let locking_cap = trail.new_capability( + &admin_cap, + &string::utf8(b"LockingAdmin"), + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(locking_cap, admin); + admin_cap.destroy_for_testing(); + ts::return_shared(trail); + }; + + // Update from no-locking to time-based + ts::next_tx(&mut scenario, admin); + { + let locking_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + clock.set_for_testing(initial_time_for_testing() + 1000); + + // Initially unlocked + assert!(!trail.is_record_locked(0, &clock), 0); + + // Update to 1 hour time-based locking + trail.update_locking_config(&locking_cap, locking::time_based(3600), ts::ctx(&mut scenario)); + + // Now locked + assert!(trail.is_record_locked(0, &clock), 1); + + clock::destroy_for_testing(clock); + locking_cap.destroy_for_testing(); + ts::return_shared(trail); + }; + + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = main::EPermissionDenied)] +fun test_update_locking_config_permission_denied() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + { + let locking_config = locking::none(); + let (admin_cap, _) = setup_test_audit_trail(&mut scenario, locking_config, std::option::none()); + transfer::public_transfer(admin_cap, admin); + }; + + // Create role WITHOUT UpdateLockingConfig permission + ts::next_tx(&mut scenario, admin); + { + let admin_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + + let perms = permission::from_vec(vector[permission::add_record()]); + trail.create_role(&admin_cap, string::utf8(b"NoLockingPerm"), perms, ts::ctx(&mut scenario)); + + let no_locking_cap = trail.new_capability( + &admin_cap, + &string::utf8(b"NoLockingPerm"), + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(no_locking_cap, admin); + admin_cap.destroy_for_testing(); + ts::return_shared(trail); + }; + + // Try to update locking config - should fail + ts::next_tx(&mut scenario, admin); + { + let no_locking_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + + trail.update_locking_config(&no_locking_cap, locking::time_based(3600), ts::ctx(&mut scenario)); + + no_locking_cap.destroy_for_testing(); + ts::return_shared(trail); + }; + + ts::end(scenario); +} + +#[test] +fun test_update_locking_config_for_delete_record() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + // Create trail with no locking + { + let locking_config = locking::none(); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::some(new_test_data(1, b"Test")), + ); + transfer::public_transfer(admin_cap, admin); + }; + + // Create role with UpdateLockingConfigForDeleteRecord permission + ts::next_tx(&mut scenario, admin); + { + let admin_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + + let perms = permission::from_vec(vector[permission::update_locking_config_for_delete_record()]); + trail.create_role(&admin_cap, string::utf8(b"DeleteLockAdmin"), perms, ts::ctx(&mut scenario)); + + let delete_lock_cap = trail.new_capability( + &admin_cap, + &string::utf8(b"DeleteLockAdmin"), + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(delete_lock_cap, admin); + admin_cap.destroy_for_testing(); + ts::return_shared(trail); + }; + + // Update delete_record_lock + ts::next_tx(&mut scenario, admin); + { + let delete_lock_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + clock.set_for_testing(initial_time_for_testing() + 1000); + + // Initially unlocked + assert!(!trail.is_record_locked(0, &clock), 0); + + // Update to count-based (last 5 locked) + trail.update_locking_config_for_delete_record(&delete_lock_cap, locking::window_count_based(5), ts::ctx(&mut scenario)); + + // Now locked (single record, last 5 are locked) + assert!(trail.is_record_locked(0, &clock), 1); + + clock::destroy_for_testing(clock); + delete_lock_cap.destroy_for_testing(); + ts::return_shared(trail); + }; + + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = main::EPermissionDenied)] +fun test_update_locking_config_for_delete_record_permission_denied() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + { + let locking_config = locking::none(); + let (admin_cap, _) = setup_test_audit_trail(&mut scenario, locking_config, std::option::none()); + transfer::public_transfer(admin_cap, admin); + }; + + // Create role with update_locking_config but NOT update_locking_config_for_delete_record + ts::next_tx(&mut scenario, admin); + { + let admin_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + + let perms = permission::from_vec(vector[permission::update_locking_config()]); + trail.create_role(&admin_cap, string::utf8(b"WrongPerm"), perms, ts::ctx(&mut scenario)); + + let wrong_cap = trail.new_capability( + &admin_cap, + &string::utf8(b"WrongPerm"), + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(wrong_cap, admin); + admin_cap.destroy_for_testing(); + ts::return_shared(trail); + }; + + // Try to update delete_record_lock - should fail + ts::next_tx(&mut scenario, admin); + { + let wrong_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + + trail.update_locking_config_for_delete_record(&wrong_cap, locking::window_count_based(5), ts::ctx(&mut scenario)); + + wrong_cap.destroy_for_testing(); + ts::return_shared(trail); + }; + + ts::end(scenario); +} diff --git a/audit-trail-move/tests/metadata_tests.move b/audit-trail-move/tests/metadata_tests.move new file mode 100644 index 00000000..db2e8a59 --- /dev/null +++ b/audit-trail-move/tests/metadata_tests.move @@ -0,0 +1,276 @@ +#[test_only] +module audit_trail::metadata_tests; + +use audit_trail::{ + capability::Capability, + locking, + main::{Self, AuditTrail}, + permission, + test_utils::{TestData, setup_test_audit_trail} +}; +use iota::test_scenario as ts; +use std::string; + +// ===== Success Case Tests ===== + +#[test] +fun test_update_metadata_success() { + let admin_user = @0xAD; + let metadata_admin_user = @0xB0B; + + let mut scenario = ts::begin(admin_user); + + // Setup: Create audit trail + { + let locking_config = locking::new(locking::window_count_based(0)); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::none(), + ); + transfer::public_transfer(admin_cap, admin_user); + }; + + // Create MetadataAdmin role and capability + ts::next_tx(&mut scenario, admin_user); + { + let admin_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + + // Create MetadataAdmin role with metadata permissions + let metadata_perms = permission::metadata_admin_permissions(); + trail.create_role( + &admin_cap, + string::utf8(b"MetadataAdmin"), + metadata_perms, + ts::ctx(&mut scenario), + ); + + // Issue capability to metadata admin user + let metadata_cap = trail.new_capability( + &admin_cap, + &string::utf8(b"MetadataAdmin"), + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(metadata_cap, metadata_admin_user); + ts::return_to_sender(&scenario, admin_cap); + ts::return_shared(trail); + }; + + // Test: MetadataAdmin updates metadata + ts::next_tx(&mut scenario, metadata_admin_user); + { + let metadata_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + + // Update metadata + let new_metadata = std::option::some(string::utf8(b"Updated metadata value")); + trail.update_metadata( + &metadata_cap, + new_metadata, + ts::ctx(&mut scenario), + ); + + // Verify metadata was updated + let current_metadata = trail.metadata(); + assert!(current_metadata.is_some(), 0); + assert!(*current_metadata.borrow() == string::utf8(b"Updated metadata value"), 1); + + ts::return_to_sender(&scenario, metadata_cap); + ts::return_shared(trail); + }; + + // Test: Update metadata again to verify multiple updates work + ts::next_tx(&mut scenario, metadata_admin_user); + { + let metadata_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + + // Update to different value + let new_metadata = std::option::some(string::utf8(b"Second update")); + trail.update_metadata( + &metadata_cap, + new_metadata, + ts::ctx(&mut scenario), + ); + + // Verify metadata was updated + let current_metadata = trail.metadata(); + assert!(current_metadata.is_some(), 2); + assert!(*current_metadata.borrow() == string::utf8(b"Second update"), 3); + + ts::return_to_sender(&scenario, metadata_cap); + ts::return_shared(trail); + }; + + // Test: Set metadata to none + ts::next_tx(&mut scenario, metadata_admin_user); + { + let metadata_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + + // Set to none + trail.update_metadata( + &metadata_cap, + std::option::none(), + ts::ctx(&mut scenario), + ); + + // Verify metadata is now none + let current_metadata = trail.metadata(); + assert!(current_metadata.is_none(), 4); + + ts::return_to_sender(&scenario, metadata_cap); + ts::return_shared(trail); + }; + + ts::end(scenario); +} + +// ===== Error Case Tests ===== + +#[test] +#[expected_failure(abort_code = main::EPermissionDenied)] +fun test_update_metadata_permission_denied() { + let admin_user = @0xAD; + let user = @0xB0B; + + let mut scenario = ts::begin(admin_user); + + // Setup + { + let locking_config = locking::new(locking::window_count_based(0)); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::none(), + ); + transfer::public_transfer(admin_cap, admin_user); + }; + + // Create role WITHOUT update_metadata permission + ts::next_tx(&mut scenario, admin_user); + { + let admin_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + + // Create role with only add_record permission (no update_metadata) + let perms = permission::from_vec(vector[permission::add_record()]); + trail.create_role( + &admin_cap, + string::utf8(b"NoMetadataPerm"), + perms, + ts::ctx(&mut scenario), + ); + + let user_cap = trail.new_capability( + &admin_cap, + &string::utf8(b"NoMetadataPerm"), + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(user_cap, user); + ts::return_to_sender(&scenario, admin_cap); + ts::return_shared(trail); + }; + + // User tries to update metadata - should fail + ts::next_tx(&mut scenario, user); + { + let user_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + + // This should fail - no update_metadata permission + trail.update_metadata( + &user_cap, + std::option::some(string::utf8(b"Should fail")), + ts::ctx(&mut scenario), + ); + + ts::return_to_sender(&scenario, user_cap); + ts::return_shared(trail); + }; + + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = main::ECapabilityHasBeenRevoked)] +fun test_update_metadata_revoked_capability() { + let admin_user = @0xAD; + let metadata_admin_user = @0xB0B; + + let mut scenario = ts::begin(admin_user); + + // Setup: Create audit trail + { + let locking_config = locking::new(locking::window_count_based(0)); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::none(), + ); + transfer::public_transfer(admin_cap, admin_user); + }; + + // Create MetadataAdmin role and capability + ts::next_tx(&mut scenario, admin_user); + { + let admin_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + + // Create MetadataAdmin role + let metadata_perms = permission::metadata_admin_permissions(); + trail.create_role( + &admin_cap, + string::utf8(b"MetadataAdmin"), + metadata_perms, + ts::ctx(&mut scenario), + ); + + // Issue capability + let metadata_cap = trail.new_capability( + &admin_cap, + &string::utf8(b"MetadataAdmin"), + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(metadata_cap, metadata_admin_user); + ts::return_to_sender(&scenario, admin_cap); + ts::return_shared(trail); + }; + + // Revoke the capability + ts::next_tx(&mut scenario, admin_user); + { + let admin_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + let metadata_cap = ts::take_from_address(&scenario, metadata_admin_user); + + trail.revoke_capability(&admin_cap, metadata_cap.id()); + + ts::return_to_address(metadata_admin_user, metadata_cap); + ts::return_to_sender(&scenario, admin_cap); + ts::return_shared(trail); + }; + + // Try to use revoked capability - should fail + ts::next_tx(&mut scenario, metadata_admin_user); + { + let metadata_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + + // This should fail - capability has been revoked + trail.update_metadata( + &metadata_cap, + std::option::some(string::utf8(b"Should fail")), + ts::ctx(&mut scenario), + ); + + ts::return_to_sender(&scenario, metadata_cap); + ts::return_shared(trail); + }; + + ts::end(scenario); +} diff --git a/audit-trail-move/tests/record_tests.move b/audit-trail-move/tests/record_tests.move new file mode 100644 index 00000000..02e692ad --- /dev/null +++ b/audit-trail-move/tests/record_tests.move @@ -0,0 +1,733 @@ +#[test_only] +module audit_trail::record_tests; + +use audit_trail::{ + capability::Capability, + locking, + main::{Self, AuditTrail}, + permission, + test_utils::{ + TestData, + setup_test_audit_trail, + new_test_data, + initial_time_for_testing, + test_data_value, + test_data_message + } +}; +use iota::{clock, test_scenario as ts}; +use std::string; + +// ===== Add Record Tests ===== + +#[test] +fun test_add_record_to_empty_trail() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + // Setup trail + { + let locking_config = locking::none(); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::none(), + ); + transfer::public_transfer(admin_cap, admin); + }; + + // Create RecordAdmin role + ts::next_tx(&mut scenario, admin); + { + let admin_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + + trail.create_role( + &admin_cap, + string::utf8(b"RecordAdmin"), + permission::record_admin_permissions(), + ts::ctx(&mut scenario), + ); + + let record_cap = trail.new_capability( + &admin_cap, + &string::utf8(b"RecordAdmin"), + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(record_cap, admin); + admin_cap.destroy_for_testing(); + ts::return_shared(trail); + }; + + // Add record + ts::next_tx(&mut scenario, admin); + { + let mut trail = ts::take_shared>(&scenario); + let record_cap = ts::take_from_sender(&scenario); + + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + clock.set_for_testing(initial_time_for_testing() + 1000); + + // Verify initial state + assert!(trail.record_count() == 0, 0); + assert!(trail.is_empty(), 1); + + // Add record + trail.add_record( + &record_cap, + new_test_data(42, b"First record"), + std::option::some(string::utf8(b"metadata")), + &clock, + ts::ctx(&mut scenario), + ); + + // Verify record was added + assert!(trail.record_count() == 1, 2); + assert!(!trail.is_empty(), 3); + assert!(trail.has_record(0), 4); + + clock::destroy_for_testing(clock); + ts::return_to_sender(&scenario, record_cap); + ts::return_shared(trail); + }; + + ts::end(scenario); +} + +#[test] +fun test_add_multiple_records() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + // Setup trail + { + let locking_config = locking::none(); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::none(), + ); + transfer::public_transfer(admin_cap, admin); + }; + + // Create RecordAdmin role + ts::next_tx(&mut scenario, admin); + { + let admin_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + + trail.create_role( + &admin_cap, + string::utf8(b"RecordAdmin"), + permission::record_admin_permissions(), + ts::ctx(&mut scenario), + ); + + let record_cap = trail.new_capability( + &admin_cap, + &string::utf8(b"RecordAdmin"), + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(record_cap, admin); + admin_cap.destroy_for_testing(); + ts::return_shared(trail); + }; + + // Add multiple records + ts::next_tx(&mut scenario, admin); + { + let mut trail = ts::take_shared>(&scenario); + let record_cap = ts::take_from_sender(&scenario); + + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + clock.set_for_testing(initial_time_for_testing() + 1000); + + // Add 3 records + let mut i = 0u64; + while (i < 3) { + trail.add_record( + &record_cap, + new_test_data(i, b"Record"), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + i = i + 1; + }; + + // Verify all records exist + assert!(trail.record_count() == 3, 0); + assert!(trail.has_record(0), 1); + assert!(trail.has_record(1), 2); + assert!(trail.has_record(2), 3); + assert!(!trail.has_record(3), 4); + + clock::destroy_for_testing(clock); + ts::return_to_sender(&scenario, record_cap); + ts::return_shared(trail); + }; + + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = main::EPermissionDenied)] +fun test_add_record_permission_denied() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + // Setup trail + { + let locking_config = locking::none(); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::none(), + ); + transfer::public_transfer(admin_cap, admin); + }; + + // Create role WITHOUT AddRecord permission + ts::next_tx(&mut scenario, admin); + { + let admin_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + + let perms = permission::from_vec(vector[permission::delete_record()]); + trail.create_role(&admin_cap, string::utf8(b"NoAddPerm"), perms, ts::ctx(&mut scenario)); + + let no_add_cap = trail.new_capability( + &admin_cap, + &string::utf8(b"NoAddPerm"), + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(no_add_cap, admin); + admin_cap.destroy_for_testing(); + ts::return_shared(trail); + }; + + // Try to add record - should fail + ts::next_tx(&mut scenario, admin); + { + let mut trail = ts::take_shared>(&scenario); + let no_add_cap = ts::take_from_sender(&scenario); + + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + clock.set_for_testing(initial_time_for_testing() + 1000); + + // This should fail - no AddRecord permission + trail.add_record( + &no_add_cap, + new_test_data(1, b"Should fail"), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + clock::destroy_for_testing(clock); + ts::return_to_sender(&scenario, no_add_cap); + ts::return_shared(trail); + }; + + ts::end(scenario); +} + +// ===== Delete Record Tests ===== + +#[test] +fun test_delete_record_success() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + // Setup trail with initial record + { + let locking_config = locking::none(); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::some(new_test_data(1, b"Initial")), + ); + transfer::public_transfer(admin_cap, admin); + }; + + // Create RecordAdmin role + ts::next_tx(&mut scenario, admin); + { + let admin_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + + trail.create_role( + &admin_cap, + string::utf8(b"RecordAdmin"), + permission::record_admin_permissions(), + ts::ctx(&mut scenario), + ); + + let record_cap = trail.new_capability( + &admin_cap, + &string::utf8(b"RecordAdmin"), + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(record_cap, admin); + admin_cap.destroy_for_testing(); + ts::return_shared(trail); + }; + + // Delete record + ts::next_tx(&mut scenario, admin); + { + let mut trail = ts::take_shared>(&scenario); + let record_cap = ts::take_from_sender(&scenario); + + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + clock.set_for_testing(initial_time_for_testing() + 1000); + + // Verify initial state + assert!(trail.record_count() == 1, 0); + assert!(trail.has_record(0), 1); + + // Delete record + trail.delete_record(&record_cap, 0, &clock, ts::ctx(&mut scenario)); + + // Verify record was deleted + assert!(trail.record_count() == 1, 2); // record_count doesn't decrease + assert!(!trail.has_record(0), 3); // but record is gone + + clock::destroy_for_testing(clock); + ts::return_to_sender(&scenario, record_cap); + ts::return_shared(trail); + }; + + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = main::EPermissionDenied)] +fun test_delete_record_permission_denied() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + // Setup trail with initial record + { + let locking_config = locking::none(); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::some(new_test_data(1, b"Initial")), + ); + transfer::public_transfer(admin_cap, admin); + }; + + // Create role WITHOUT DeleteRecord permission + ts::next_tx(&mut scenario, admin); + { + let admin_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + + let perms = permission::from_vec(vector[permission::add_record()]); + trail.create_role(&admin_cap, string::utf8(b"NoDeletePerm"), perms, ts::ctx(&mut scenario)); + + let no_delete_cap = trail.new_capability( + &admin_cap, + &string::utf8(b"NoDeletePerm"), + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(no_delete_cap, admin); + admin_cap.destroy_for_testing(); + ts::return_shared(trail); + }; + + // Try to delete record - should fail + ts::next_tx(&mut scenario, admin); + { + let mut trail = ts::take_shared>(&scenario); + let no_delete_cap = ts::take_from_sender(&scenario); + + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + clock.set_for_testing(initial_time_for_testing() + 1000); + + // This should fail - no DeleteRecord permission + trail.delete_record(&no_delete_cap, 0, &clock, ts::ctx(&mut scenario)); + + clock::destroy_for_testing(clock); + ts::return_to_sender(&scenario, no_delete_cap); + ts::return_shared(trail); + }; + + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = main::ERecordNotFound)] +fun test_delete_record_not_found() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + // Setup trail (no initial record) + { + let locking_config = locking::none(); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::none(), + ); + transfer::public_transfer(admin_cap, admin); + }; + + // Create RecordAdmin role + ts::next_tx(&mut scenario, admin); + { + let admin_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + + trail.create_role( + &admin_cap, + string::utf8(b"RecordAdmin"), + permission::record_admin_permissions(), + ts::ctx(&mut scenario), + ); + + let record_cap = trail.new_capability( + &admin_cap, + &string::utf8(b"RecordAdmin"), + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(record_cap, admin); + admin_cap.destroy_for_testing(); + ts::return_shared(trail); + }; + + // Try to delete non-existent record - should fail + ts::next_tx(&mut scenario, admin); + { + let mut trail = ts::take_shared>(&scenario); + let record_cap = ts::take_from_sender(&scenario); + + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + clock.set_for_testing(initial_time_for_testing() + 1000); + + // This should fail - record doesn't exist + trail.delete_record(&record_cap, 999, &clock, ts::ctx(&mut scenario)); + + clock::destroy_for_testing(clock); + ts::return_to_sender(&scenario, record_cap); + ts::return_shared(trail); + }; + + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = main::ERecordLocked)] +fun test_delete_record_time_locked() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + // Setup trail with time-based locking and initial record + { + let locking_config = locking::time_based(3600); // 1 hour + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::some(new_test_data(1, b"Locked record")), + ); + transfer::public_transfer(admin_cap, admin); + }; + + // Create RecordAdmin role + ts::next_tx(&mut scenario, admin); + { + let admin_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + + trail.create_role( + &admin_cap, + string::utf8(b"RecordAdmin"), + permission::record_admin_permissions(), + ts::ctx(&mut scenario), + ); + + let record_cap = trail.new_capability( + &admin_cap, + &string::utf8(b"RecordAdmin"), + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(record_cap, admin); + admin_cap.destroy_for_testing(); + ts::return_shared(trail); + }; + + // Try to delete locked record - should fail + ts::next_tx(&mut scenario, admin); + { + let mut trail = ts::take_shared>(&scenario); + let record_cap = ts::take_from_sender(&scenario); + + // Time is only 1 second after creation - still within lock window + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + clock.set_for_testing(initial_time_for_testing() + 1000); // +1 second + + // This should fail - record is time-locked + trail.delete_record(&record_cap, 0, &clock, ts::ctx(&mut scenario)); + + clock::destroy_for_testing(clock); + ts::return_to_sender(&scenario, record_cap); + ts::return_shared(trail); + }; + + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = main::ERecordLocked)] +fun test_delete_record_count_locked() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + // Setup trail with count-based locking and initial record + { + let locking_config = locking::count_based(5); // Last 5 records locked + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::some(new_test_data(1, b"Locked record")), + ); + transfer::public_transfer(admin_cap, admin); + }; + + // Create RecordAdmin role + ts::next_tx(&mut scenario, admin); + { + let admin_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + + trail.create_role( + &admin_cap, + string::utf8(b"RecordAdmin"), + permission::record_admin_permissions(), + ts::ctx(&mut scenario), + ); + + let record_cap = trail.new_capability( + &admin_cap, + &string::utf8(b"RecordAdmin"), + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(record_cap, admin); + admin_cap.destroy_for_testing(); + ts::return_shared(trail); + }; + + // Try to delete locked record - should fail + ts::next_tx(&mut scenario, admin); + { + let mut trail = ts::take_shared>(&scenario); + let record_cap = ts::take_from_sender(&scenario); + + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + clock.set_for_testing(initial_time_for_testing() + 1000); + + // Only 1 record exists, and last 5 are locked, so it's locked + trail.delete_record(&record_cap, 0, &clock, ts::ctx(&mut scenario)); + + clock::destroy_for_testing(clock); + ts::return_to_sender(&scenario, record_cap); + ts::return_shared(trail); + }; + + ts::end(scenario); +} + +// ===== Query Function Tests ===== + +#[test] +fun test_get_record() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + // Setup trail with initial record + { + let locking_config = locking::none(); + let initial_data = new_test_data(42, b"Test data"); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::some(initial_data), + ); + admin_cap.destroy_for_testing(); + }; + + ts::next_tx(&mut scenario, admin); + { + let trail = ts::take_shared>(&scenario); + + let record = trail.get_record(0); + let data = audit_trail::record::data(record); + + assert!(data.test_data_value() == 42, 0); + assert!(data.test_data_message() == b"Test data", 1); + + ts::return_shared(trail); + }; + + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = main::ERecordNotFound)] +fun test_get_record_not_found() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + // Setup trail (no initial record) + { + let locking_config = locking::none(); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::none(), + ); + admin_cap.destroy_for_testing(); + }; + + ts::next_tx(&mut scenario, admin); + { + let trail = ts::take_shared>(&scenario); + + // This should fail - no records exist + let _record = trail.get_record(0); + + ts::return_shared(trail); + }; + + ts::end(scenario); +} + +#[test] +fun test_first_last_sequence() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + // Setup trail + { + let locking_config = locking::none(); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::none(), + ); + transfer::public_transfer(admin_cap, admin); + }; + + // Create RecordAdmin and test sequence functions + ts::next_tx(&mut scenario, admin); + { + let admin_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + + // Empty trail + assert!(trail.first_sequence().is_none(), 0); + assert!(trail.last_sequence().is_none(), 1); + + trail.create_role( + &admin_cap, + string::utf8(b"RecordAdmin"), + permission::record_admin_permissions(), + ts::ctx(&mut scenario), + ); + + let record_cap = trail.new_capability( + &admin_cap, + &string::utf8(b"RecordAdmin"), + ts::ctx(&mut scenario), + ); + + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + clock.set_for_testing(initial_time_for_testing() + 1000); + + // Add first record + trail.add_record( + &record_cap, + new_test_data(1, b"First"), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + assert!(trail.first_sequence() == std::option::some(0), 2); + assert!(trail.last_sequence() == std::option::some(0), 3); + + // Add second record + trail.add_record( + &record_cap, + new_test_data(2, b"Second"), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + assert!(trail.first_sequence() == std::option::some(0), 4); + assert!(trail.last_sequence() == std::option::some(1), 5); + + // Add third record + trail.add_record( + &record_cap, + new_test_data(3, b"Third"), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + assert!(trail.first_sequence() == std::option::some(0), 6); + assert!(trail.last_sequence() == std::option::some(2), 7); + + clock::destroy_for_testing(clock); + admin_cap.destroy_for_testing(); + record_cap.destroy_for_testing(); + ts::return_shared(trail); + }; + + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = main::ERecordNotFound)] +fun test_is_record_locked_not_found() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + // Setup trail (no initial record) + { + let locking_config = locking::time_based(3600); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::none(), + ); + admin_cap.destroy_for_testing(); + }; + + ts::next_tx(&mut scenario, admin); + { + let trail = ts::take_shared>(&scenario); + + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + clock.set_for_testing(initial_time_for_testing() + 1000); + + // This should fail - record doesn't exist + let _locked = trail.is_record_locked(0, &clock); + + clock::destroy_for_testing(clock); + ts::return_shared(trail); + }; + + ts::end(scenario); +} diff --git a/audit-trail-move/tests/role_tests.move b/audit-trail-move/tests/role_tests.move index c2bf1ce7..04f1335e 100644 --- a/audit-trail-move/tests/role_tests.move +++ b/audit-trail-move/tests/role_tests.move @@ -11,21 +11,6 @@ use audit_trail::{ use iota::{clock, test_scenario as ts}; use std::string; -/// Test comprehensive role-based access control delegation workflow. -/// -/// This test validates the complete permission delegation chain: -/// 1. An admin user creates an audit trail with full admin permissions -/// 2. Admin creates two specialized roles: RoleAdmin (for role management) and CapAdmin (for capability management) -/// 3. Admin delegates these roles to different users by issuing capabilities -/// 4. RoleAdmin user leverages their permissions to create a RecordAdmin role -/// 5. CapAdmin user leverages their permissions to issue a RecordAdmin capability -/// 6. RecordAdmin user uses their capability to add a record to the audit trail -/// -/// This test ensures: -/// - Role creation works correctly with specific permission sets -/// - Capability issuance and transfer functions properly -/// - Permission delegation cascade works (Admin -> RoleAdmin -> RecordAdmin) -/// - Permission delegation cascade works (Admin -> CapAdmin -> RecordAdmin capability) #[test] fun test_role_based_permission_delegation() { let admin_user = @0xAD; @@ -212,3 +197,229 @@ fun test_role_based_permission_delegation() { ts::next_tx(&mut scenario, admin_user); ts::end(scenario); } + +// ===== Error Case Tests ===== + +#[test] +#[expected_failure(abort_code = main::EPermissionDenied)] +fun test_create_role_permission_denied() { + let admin_user = @0xAD; + let user = @0xB0B; + + let mut scenario = ts::begin(admin_user); + + // Setup + { + let locking_config = locking::new(locking::window_count_based(0)); + let (admin_cap, _) = setup_test_audit_trail(&mut scenario, locking_config, std::option::none()); + transfer::public_transfer(admin_cap, admin_user); + }; + + // Create role without RolesAdd permission + ts::next_tx(&mut scenario, admin_user); + { + let admin_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + + // Create role WITHOUT add_roles permission + let perms = permission::from_vec(vector[permission::add_record()]); + trail.create_role(&admin_cap, string::utf8(b"NoRolesPerm"), perms, ts::ctx(&mut scenario)); + + let user_cap = trail.new_capability( + &admin_cap, + &string::utf8(b"NoRolesPerm"), + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(user_cap, user); + ts::return_to_sender(&scenario, admin_cap); + ts::return_shared(trail); + }; + + // User tries to create a role - should fail + ts::next_tx(&mut scenario, user); + { + let user_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + + let perms = permission::from_vec(vector[permission::add_record()]); + + // This should fail - no add_roles permission + trail.create_role(&user_cap, string::utf8(b"NewRole"), perms, ts::ctx(&mut scenario)); + + ts::return_to_sender(&scenario, user_cap); + ts::return_shared(trail); + }; + + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = main::EPermissionDenied)] +fun test_delete_role_permission_denied() { + let admin_user = @0xAD; + let user = @0xB0B; + + let mut scenario = ts::begin(admin_user); + + // Setup + { + let locking_config = locking::new(locking::window_count_based(0)); + let (admin_cap, _) = setup_test_audit_trail(&mut scenario, locking_config, std::option::none()); + transfer::public_transfer(admin_cap, admin_user); + }; + + // Create roles + ts::next_tx(&mut scenario, admin_user); + { + let admin_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + + // Create a role to delete + let perms = permission::from_vec(vector[permission::add_record()]); + trail.create_role(&admin_cap, string::utf8(b"RoleToDelete"), perms, ts::ctx(&mut scenario)); + + // Create role WITHOUT delete_roles permission + let no_delete_perms = permission::from_vec(vector[permission::add_record()]); + trail.create_role(&admin_cap, string::utf8(b"NoDeleteRolePerm"), no_delete_perms, ts::ctx(&mut scenario)); + + let user_cap = trail.new_capability( + &admin_cap, + &string::utf8(b"NoDeleteRolePerm"), + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(user_cap, user); + ts::return_to_sender(&scenario, admin_cap); + ts::return_shared(trail); + }; + + // User tries to delete a role - should fail + ts::next_tx(&mut scenario, user); + { + let user_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + + // This should fail - no delete_roles permission + trail.delete_role(&user_cap, &string::utf8(b"RoleToDelete"), ts::ctx(&mut scenario)); + + ts::return_to_sender(&scenario, user_cap); + ts::return_shared(trail); + }; + + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = main::EPermissionDenied)] +fun test_update_role_permissions_permission_denied() { + let admin_user = @0xAD; + let user = @0xB0B; + + let mut scenario = ts::begin(admin_user); + + // Setup + { + let locking_config = locking::new(locking::window_count_based(0)); + let (admin_cap, _) = setup_test_audit_trail(&mut scenario, locking_config, std::option::none()); + transfer::public_transfer(admin_cap, admin_user); + }; + + // Create roles + ts::next_tx(&mut scenario, admin_user); + { + let admin_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + + // Create a role to update + let perms = permission::from_vec(vector[permission::add_record()]); + trail.create_role(&admin_cap, string::utf8(b"RoleToUpdate"), perms, ts::ctx(&mut scenario)); + + // Create role WITHOUT update_roles permission + let no_update_perms = permission::from_vec(vector[permission::add_record()]); + trail.create_role(&admin_cap, string::utf8(b"NoUpdateRolePerm"), no_update_perms, ts::ctx(&mut scenario)); + + let user_cap = trail.new_capability( + &admin_cap, + &string::utf8(b"NoUpdateRolePerm"), + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(user_cap, user); + ts::return_to_sender(&scenario, admin_cap); + ts::return_shared(trail); + }; + + // User tries to update a role - should fail + ts::next_tx(&mut scenario, user); + { + let user_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + + let new_perms = permission::from_vec(vector[permission::delete_record()]); + + // This should fail - no update_roles permission + trail.update_role_permissions(&user_cap, &string::utf8(b"RoleToUpdate"), new_perms, ts::ctx(&mut scenario)); + + ts::return_to_sender(&scenario, user_cap); + ts::return_shared(trail); + }; + + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = main::ERoleDoesNotExist)] +fun test_get_role_permissions_nonexistent() { + let admin_user = @0xAD; + + let mut scenario = ts::begin(admin_user); + + { + let locking_config = locking::new(locking::window_count_based(0)); + let (admin_cap, _) = setup_test_audit_trail(&mut scenario, locking_config, std::option::none()); + admin_cap.destroy_for_testing(); + }; + + ts::next_tx(&mut scenario, admin_user); + { + let trail = ts::take_shared>(&scenario); + + // This should fail - role doesn't exist + let _perms = trail.get_role_permissions(&string::utf8(b"NonExistentRole")); + + ts::return_shared(trail); + }; + + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = main::ERoleDoesNotExist)] +fun test_update_role_permissions_nonexistent() { + let admin_user = @0xAD; + + let mut scenario = ts::begin(admin_user); + + { + let locking_config = locking::new(locking::window_count_based(0)); + let (admin_cap, _) = setup_test_audit_trail(&mut scenario, locking_config, std::option::none()); + transfer::public_transfer(admin_cap, admin_user); + }; + + ts::next_tx(&mut scenario, admin_user); + { + let admin_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + + let new_perms = permission::from_vec(vector[permission::add_record()]); + + // This should fail - role doesn't exist + trail.update_role_permissions(&admin_cap, &string::utf8(b"NonExistentRole"), new_perms, ts::ctx(&mut scenario)); + + ts::return_to_sender(&scenario, admin_cap); + ts::return_shared(trail); + }; + + ts::end(scenario); +} diff --git a/audit-trail-move/tests/test_utils.move b/audit-trail-move/tests/test_utils.move index ee729f22..75c5d996 100644 --- a/audit-trail-move/tests/test_utils.move +++ b/audit-trail-move/tests/test_utils.move @@ -20,6 +20,14 @@ public(package) fun new_test_data(value: u64, message: vector): TestData { } } +public(package) fun test_data_value(data: &TestData): u64 { + data.value +} + +public(package) fun test_data_message(data: &TestData): vector { + data.message +} + public(package) fun initial_time_for_testing(): u64 { INITIAL_TIME_FOR_TESTING } @@ -34,7 +42,7 @@ public(package) fun setup_test_audit_trail( let mut clock = clock::create_for_testing(ts::ctx(scenario)); clock.set_for_testing(INITIAL_TIME_FOR_TESTING); - let trail_metadata = main::new_trail_metadata( + let trail_metadata = main::new_metadata( std::option::some(string::utf8(b"Setup Test Trail")), std::option::none(), ); From d55e41bb8876f133e5768a0c56bf4da8ec47ffc7 Mon Sep 17 00:00:00 2001 From: Yasir Date: Mon, 12 Jan 2026 19:52:39 +0300 Subject: [PATCH 028/189] refactor: improve test readability with consistent formatting - Reformatted function calls in locking and role tests for better readability by aligning parameters across multiple lines. - Enhanced clarity in test cases related to locking configurations and role permissions, ensuring easier maintenance and understanding of the test logic. --- audit-trail-move/tests/locking_tests.move | 68 +++++++++++++++++++---- audit-trail-move/tests/role_tests.move | 58 ++++++++++++++++--- 2 files changed, 106 insertions(+), 20 deletions(-) diff --git a/audit-trail-move/tests/locking_tests.move b/audit-trail-move/tests/locking_tests.move index fea157b4..a4cce9a0 100644 --- a/audit-trail-move/tests/locking_tests.move +++ b/audit-trail-move/tests/locking_tests.move @@ -99,7 +99,11 @@ fun test_count_based_locking() { // Create trail with count-based locking (last 2 locked) { let locking_config = locking::count_based(2); - let (admin_cap, _) = setup_test_audit_trail(&mut scenario, locking_config, std::option::none()); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::none(), + ); transfer::public_transfer(admin_cap, admin); }; @@ -138,7 +142,13 @@ fun test_count_based_locking() { let mut i = 0u64; while (i < 5) { - trail.add_record(&record_cap, new_test_data(i, b"Record"), std::option::none(), &clock, ts::ctx(&mut scenario)); + trail.add_record( + &record_cap, + new_test_data(i, b"Record"), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); i = i + 1; }; @@ -274,7 +284,11 @@ fun test_update_locking_config() { assert!(!trail.is_record_locked(0, &clock), 0); // Update to 1 hour time-based locking - trail.update_locking_config(&locking_cap, locking::time_based(3600), ts::ctx(&mut scenario)); + trail.update_locking_config( + &locking_cap, + locking::time_based(3600), + ts::ctx(&mut scenario), + ); // Now locked assert!(trail.is_record_locked(0, &clock), 1); @@ -295,7 +309,11 @@ fun test_update_locking_config_permission_denied() { { let locking_config = locking::none(); - let (admin_cap, _) = setup_test_audit_trail(&mut scenario, locking_config, std::option::none()); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::none(), + ); transfer::public_transfer(admin_cap, admin); }; @@ -306,7 +324,12 @@ fun test_update_locking_config_permission_denied() { let mut trail = ts::take_shared>(&scenario); let perms = permission::from_vec(vector[permission::add_record()]); - trail.create_role(&admin_cap, string::utf8(b"NoLockingPerm"), perms, ts::ctx(&mut scenario)); + trail.create_role( + &admin_cap, + string::utf8(b"NoLockingPerm"), + perms, + ts::ctx(&mut scenario), + ); let no_locking_cap = trail.new_capability( &admin_cap, @@ -325,7 +348,11 @@ fun test_update_locking_config_permission_denied() { let no_locking_cap = ts::take_from_sender(&scenario); let mut trail = ts::take_shared>(&scenario); - trail.update_locking_config(&no_locking_cap, locking::time_based(3600), ts::ctx(&mut scenario)); + trail.update_locking_config( + &no_locking_cap, + locking::time_based(3600), + ts::ctx(&mut scenario), + ); no_locking_cap.destroy_for_testing(); ts::return_shared(trail); @@ -356,8 +383,15 @@ fun test_update_locking_config_for_delete_record() { let admin_cap = ts::take_from_sender(&scenario); let mut trail = ts::take_shared>(&scenario); - let perms = permission::from_vec(vector[permission::update_locking_config_for_delete_record()]); - trail.create_role(&admin_cap, string::utf8(b"DeleteLockAdmin"), perms, ts::ctx(&mut scenario)); + let perms = permission::from_vec(vector[ + permission::update_locking_config_for_delete_record(), + ]); + trail.create_role( + &admin_cap, + string::utf8(b"DeleteLockAdmin"), + perms, + ts::ctx(&mut scenario), + ); let delete_lock_cap = trail.new_capability( &admin_cap, @@ -383,7 +417,11 @@ fun test_update_locking_config_for_delete_record() { assert!(!trail.is_record_locked(0, &clock), 0); // Update to count-based (last 5 locked) - trail.update_locking_config_for_delete_record(&delete_lock_cap, locking::window_count_based(5), ts::ctx(&mut scenario)); + trail.update_locking_config_for_delete_record( + &delete_lock_cap, + locking::window_count_based(5), + ts::ctx(&mut scenario), + ); // Now locked (single record, last 5 are locked) assert!(trail.is_record_locked(0, &clock), 1); @@ -404,7 +442,11 @@ fun test_update_locking_config_for_delete_record_permission_denied() { { let locking_config = locking::none(); - let (admin_cap, _) = setup_test_audit_trail(&mut scenario, locking_config, std::option::none()); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::none(), + ); transfer::public_transfer(admin_cap, admin); }; @@ -434,7 +476,11 @@ fun test_update_locking_config_for_delete_record_permission_denied() { let wrong_cap = ts::take_from_sender(&scenario); let mut trail = ts::take_shared>(&scenario); - trail.update_locking_config_for_delete_record(&wrong_cap, locking::window_count_based(5), ts::ctx(&mut scenario)); + trail.update_locking_config_for_delete_record( + &wrong_cap, + locking::window_count_based(5), + ts::ctx(&mut scenario), + ); wrong_cap.destroy_for_testing(); ts::return_shared(trail); diff --git a/audit-trail-move/tests/role_tests.move b/audit-trail-move/tests/role_tests.move index 04f1335e..20c8f2c3 100644 --- a/audit-trail-move/tests/role_tests.move +++ b/audit-trail-move/tests/role_tests.move @@ -211,7 +211,11 @@ fun test_create_role_permission_denied() { // Setup { let locking_config = locking::new(locking::window_count_based(0)); - let (admin_cap, _) = setup_test_audit_trail(&mut scenario, locking_config, std::option::none()); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::none(), + ); transfer::public_transfer(admin_cap, admin_user); }; @@ -265,7 +269,11 @@ fun test_delete_role_permission_denied() { // Setup { let locking_config = locking::new(locking::window_count_based(0)); - let (admin_cap, _) = setup_test_audit_trail(&mut scenario, locking_config, std::option::none()); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::none(), + ); transfer::public_transfer(admin_cap, admin_user); }; @@ -281,7 +289,12 @@ fun test_delete_role_permission_denied() { // Create role WITHOUT delete_roles permission let no_delete_perms = permission::from_vec(vector[permission::add_record()]); - trail.create_role(&admin_cap, string::utf8(b"NoDeleteRolePerm"), no_delete_perms, ts::ctx(&mut scenario)); + trail.create_role( + &admin_cap, + string::utf8(b"NoDeleteRolePerm"), + no_delete_perms, + ts::ctx(&mut scenario), + ); let user_cap = trail.new_capability( &admin_cap, @@ -321,7 +334,11 @@ fun test_update_role_permissions_permission_denied() { // Setup { let locking_config = locking::new(locking::window_count_based(0)); - let (admin_cap, _) = setup_test_audit_trail(&mut scenario, locking_config, std::option::none()); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::none(), + ); transfer::public_transfer(admin_cap, admin_user); }; @@ -337,7 +354,12 @@ fun test_update_role_permissions_permission_denied() { // Create role WITHOUT update_roles permission let no_update_perms = permission::from_vec(vector[permission::add_record()]); - trail.create_role(&admin_cap, string::utf8(b"NoUpdateRolePerm"), no_update_perms, ts::ctx(&mut scenario)); + trail.create_role( + &admin_cap, + string::utf8(b"NoUpdateRolePerm"), + no_update_perms, + ts::ctx(&mut scenario), + ); let user_cap = trail.new_capability( &admin_cap, @@ -359,7 +381,12 @@ fun test_update_role_permissions_permission_denied() { let new_perms = permission::from_vec(vector[permission::delete_record()]); // This should fail - no update_roles permission - trail.update_role_permissions(&user_cap, &string::utf8(b"RoleToUpdate"), new_perms, ts::ctx(&mut scenario)); + trail.update_role_permissions( + &user_cap, + &string::utf8(b"RoleToUpdate"), + new_perms, + ts::ctx(&mut scenario), + ); ts::return_to_sender(&scenario, user_cap); ts::return_shared(trail); @@ -377,7 +404,11 @@ fun test_get_role_permissions_nonexistent() { { let locking_config = locking::new(locking::window_count_based(0)); - let (admin_cap, _) = setup_test_audit_trail(&mut scenario, locking_config, std::option::none()); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::none(), + ); admin_cap.destroy_for_testing(); }; @@ -403,7 +434,11 @@ fun test_update_role_permissions_nonexistent() { { let locking_config = locking::new(locking::window_count_based(0)); - let (admin_cap, _) = setup_test_audit_trail(&mut scenario, locking_config, std::option::none()); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::none(), + ); transfer::public_transfer(admin_cap, admin_user); }; @@ -415,7 +450,12 @@ fun test_update_role_permissions_nonexistent() { let new_perms = permission::from_vec(vector[permission::add_record()]); // This should fail - role doesn't exist - trail.update_role_permissions(&admin_cap, &string::utf8(b"NonExistentRole"), new_perms, ts::ctx(&mut scenario)); + trail.update_role_permissions( + &admin_cap, + &string::utf8(b"NonExistentRole"), + new_perms, + ts::ctx(&mut scenario), + ); ts::return_to_sender(&scenario, admin_cap); ts::return_shared(trail); From 5f45822a4f129121c69508c8691d0117917e903e Mon Sep 17 00:00:00 2001 From: Yasir Date: Tue, 13 Jan 2026 08:37:12 +0300 Subject: [PATCH 029/189] fix: correct typos in permission documentation and improve comments - Fixed typos in comments related to the `Permission` enum and `admin_permissions` function for clarity. - Enhanced documentation to better reflect the intended roles and permissions within the audit trail system. --- audit-trail-move/sources/permission.move | 4 +- audit-trail-move/tests/locking_tests.move | 455 ++++++++++++++++++++++ 2 files changed, 457 insertions(+), 2 deletions(-) diff --git a/audit-trail-move/sources/permission.move b/audit-trail-move/sources/permission.move index ddb4c3a8..04b324e5 100644 --- a/audit-trail-move/sources/permission.move +++ b/audit-trail-move/sources/permission.move @@ -8,7 +8,7 @@ use iota::vec_set::{Self, VecSet}; /// Existing permissions for the Audit Trail object public enum Permission has copy, drop, store { - // --- Whole AUdit TRail related - Proposed role: `Admin` --- + // --- Whole Audit Trail related - Proposed role: `Admin` --- /// Destroy the whole Audit Trail object DeleteAuditTrail, // --- Record Management - Proposed role: `RecordAdmin` --- @@ -73,7 +73,7 @@ public fun has_permission(set: &VecSet, perm: &Permission): bool { // --------------------------- Functions creating permission sets for often used roles --------------------------- -/// Create permissions typical used for the `Admin` rolepermissions +/// Create permissions typically used for the `Admin` role public fun admin_permissions(): VecSet { let mut perms = vec_set::empty(); perms.insert(delete_audit_trail()); diff --git a/audit-trail-move/tests/locking_tests.move b/audit-trail-move/tests/locking_tests.move index a4cce9a0..1f9ca1bb 100644 --- a/audit-trail-move/tests/locking_tests.move +++ b/audit-trail-move/tests/locking_tests.move @@ -488,3 +488,458 @@ fun test_update_locking_config_for_delete_record_permission_denied() { ts::end(scenario); } + +#[test] +fun test_delete_record_after_time_lock_expires() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + // Create trail with 1 hour time-based locking and initial record + { + let locking_config = locking::time_based(3600); // 1 hour = 3600 seconds + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::some(new_test_data(1, b"Locked record")), + ); + transfer::public_transfer(admin_cap, admin); + }; + + // Create RecordAdmin role + ts::next_tx(&mut scenario, admin); + { + let admin_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + + trail.create_role( + &admin_cap, + string::utf8(b"RecordAdmin"), + permission::record_admin_permissions(), + ts::ctx(&mut scenario), + ); + + let record_cap = trail.new_capability( + &admin_cap, + &string::utf8(b"RecordAdmin"), + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(record_cap, admin); + admin_cap.destroy_for_testing(); + ts::return_shared(trail); + }; + + // Test boundary: exactly at lock expiry (should still be locked) + ts::next_tx(&mut scenario, admin); + { + let trail = ts::take_shared>(&scenario); + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + + // Exactly at 1 hour mark - record age equals time window (edge case) + // So at exactly the boundary, record should be UNLOCKED + clock.set_for_testing(initial_time_for_testing() + 3600 * 1000); + assert!(!trail.is_record_locked(0, &clock), 0); + + clock::destroy_for_testing(clock); + ts::return_shared(trail); + }; + + // Delete record after time lock expires + ts::next_tx(&mut scenario, admin); + { + let mut trail = ts::take_shared>(&scenario); + let record_cap = ts::take_from_sender(&scenario); + + // 1 hour + 1 second after creation - clearly past the lock window + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + clock.set_for_testing(initial_time_for_testing() + 3601 * 1000); + + // Verify record exists and is unlocked + assert!(trail.has_record(0), 1); + assert!(!trail.is_record_locked(0, &clock), 2); + + // Delete should succeed + trail.delete_record(&record_cap, 0, &clock, ts::ctx(&mut scenario)); + + // Verify record was deleted + assert!(!trail.has_record(0), 3); + + clock::destroy_for_testing(clock); + record_cap.destroy_for_testing(); + ts::return_shared(trail); + }; + + ts::end(scenario); +} + +#[test] +fun test_time_lock_boundary_just_before_expiry() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + // Create trail with 1 hour time-based locking + { + let locking_config = locking::time_based(3600); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::some(new_test_data(1, b"Test")), + ); + admin_cap.destroy_for_testing(); + }; + + ts::next_tx(&mut scenario, admin); + { + let trail = ts::take_shared>(&scenario); + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + + // 1 millisecond before lock expires - should still be locked + // 3600 * 1000 - 1 = 3599999 ms + clock.set_for_testing(initial_time_for_testing() + 3600 * 1000 - 1); + assert!(trail.is_record_locked(0, &clock), 0); + + clock::destroy_for_testing(clock); + ts::return_shared(trail); + }; + + ts::end(scenario); +} + +// ===== Combined Locking Tests ===== + +#[test] +fun test_combined_time_and_count_locking_both_lock() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + // Create trail with BOTH time-based (1 hour) and count-based (last 2) locking + { + let locking_config = locking::new( + locking::new_window(std::option::some(3600), std::option::some(2)), + ); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::none(), + ); + transfer::public_transfer(admin_cap, admin); + }; + + // Create RecordAdmin role and add records + ts::next_tx(&mut scenario, admin); + { + let admin_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + + trail.create_role( + &admin_cap, + string::utf8(b"RecordAdmin"), + permission::record_admin_permissions(), + ts::ctx(&mut scenario), + ); + + let record_cap = trail.new_capability( + &admin_cap, + &string::utf8(b"RecordAdmin"), + ts::ctx(&mut scenario), + ); + + // Add 5 records + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + clock.set_for_testing(initial_time_for_testing() + 1000); + + let mut i = 0u64; + while (i < 5) { + trail.add_record( + &record_cap, + new_test_data(i, b"Record"), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + i = i + 1; + }; + + clock::destroy_for_testing(clock); + transfer::public_transfer(record_cap, admin); + admin_cap.destroy_for_testing(); + ts::return_shared(trail); + }; + + // Test: Records locked by BOTH time and count + ts::next_tx(&mut scenario, admin); + { + let trail = ts::take_shared>(&scenario); + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + + // Shortly after creation - all records time-locked + // Records 3, 4 also count-locked (last 2) + clock.set_for_testing(initial_time_for_testing() + 2000); + + // All records should be locked (time lock active for all) + assert!(trail.is_record_locked(0, &clock), 0); + assert!(trail.is_record_locked(1, &clock), 1); + assert!(trail.is_record_locked(2, &clock), 2); + assert!(trail.is_record_locked(3, &clock), 3); + assert!(trail.is_record_locked(4, &clock), 4); + + clock::destroy_for_testing(clock); + ts::return_shared(trail); + }; + + ts::end(scenario); +} + +#[test] +fun test_combined_locking_time_expired_but_count_locked() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + // Create trail with time-based (1 hour) and count-based (last 2) locking + { + let locking_config = locking::new( + locking::new_window(std::option::some(3600), std::option::some(2)), + ); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::none(), + ); + transfer::public_transfer(admin_cap, admin); + }; + + // Create RecordAdmin role and add records + ts::next_tx(&mut scenario, admin); + { + let admin_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + + trail.create_role( + &admin_cap, + string::utf8(b"RecordAdmin"), + permission::record_admin_permissions(), + ts::ctx(&mut scenario), + ); + + let record_cap = trail.new_capability( + &admin_cap, + &string::utf8(b"RecordAdmin"), + ts::ctx(&mut scenario), + ); + + // Add 5 records + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + clock.set_for_testing(initial_time_for_testing() + 1000); + + let mut i = 0u64; + while (i < 5) { + trail.add_record( + &record_cap, + new_test_data(i, b"Record"), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + i = i + 1; + }; + + clock::destroy_for_testing(clock); + transfer::public_transfer(record_cap, admin); + admin_cap.destroy_for_testing(); + ts::return_shared(trail); + }; + + // Test: Time lock expired, but count lock still active for last 2 records + ts::next_tx(&mut scenario, admin); + { + let trail = ts::take_shared>(&scenario); + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + + // 2 hours after creation - time lock expired + clock.set_for_testing(initial_time_for_testing() + 7200 * 1000); + + // Records 0, 1, 2 should be unlocked (time expired, not in last 2) + assert!(!trail.is_record_locked(0, &clock), 0); + assert!(!trail.is_record_locked(1, &clock), 1); + assert!(!trail.is_record_locked(2, &clock), 2); + + // Records 3, 4 should still be locked (count lock - last 2) + assert!(trail.is_record_locked(3, &clock), 3); + assert!(trail.is_record_locked(4, &clock), 4); + + clock::destroy_for_testing(clock); + ts::return_shared(trail); + }; + + ts::end(scenario); +} + +#[test] +fun test_combined_locking_count_satisfied_but_time_locked() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + // Create trail with time-based (1 hour) and count-based (last 2) locking + { + let locking_config = locking::new( + locking::new_window(std::option::some(3600), std::option::some(2)), + ); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::none(), + ); + transfer::public_transfer(admin_cap, admin); + }; + + // Create RecordAdmin role and add records + ts::next_tx(&mut scenario, admin); + { + let admin_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + + trail.create_role( + &admin_cap, + string::utf8(b"RecordAdmin"), + permission::record_admin_permissions(), + ts::ctx(&mut scenario), + ); + + let record_cap = trail.new_capability( + &admin_cap, + &string::utf8(b"RecordAdmin"), + ts::ctx(&mut scenario), + ); + + // Add 5 records + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + clock.set_for_testing(initial_time_for_testing() + 1000); + + let mut i = 0u64; + while (i < 5) { + trail.add_record( + &record_cap, + new_test_data(i, b"Record"), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + i = i + 1; + }; + + clock::destroy_for_testing(clock); + transfer::public_transfer(record_cap, admin); + admin_cap.destroy_for_testing(); + ts::return_shared(trail); + }; + + // Test: Count lock satisfied (not in last 2), but time lock still active + ts::next_tx(&mut scenario, admin); + { + let trail = ts::take_shared>(&scenario); + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + + // Only 30 minutes after creation - time lock still active + clock.set_for_testing(initial_time_for_testing() + 1800 * 1000); + + // Record 0 is NOT in last 2 (count satisfied), but still time-locked + // Combined locking uses OR logic: locked if EITHER is true + assert!(trail.is_record_locked(0, &clock), 0); + assert!(trail.is_record_locked(1, &clock), 1); + assert!(trail.is_record_locked(2, &clock), 2); + + clock::destroy_for_testing(clock); + ts::return_shared(trail); + }; + + ts::end(scenario); +} + +#[test] +fun test_combined_locking_both_satisfied_can_delete() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + // Create trail with time-based (1 hour) and count-based (last 2) locking + { + let locking_config = locking::new( + locking::new_window(std::option::some(3600), std::option::some(2)), + ); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::none(), + ); + transfer::public_transfer(admin_cap, admin); + }; + + // Create RecordAdmin role and add records + ts::next_tx(&mut scenario, admin); + { + let admin_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + + trail.create_role( + &admin_cap, + string::utf8(b"RecordAdmin"), + permission::record_admin_permissions(), + ts::ctx(&mut scenario), + ); + + let record_cap = trail.new_capability( + &admin_cap, + &string::utf8(b"RecordAdmin"), + ts::ctx(&mut scenario), + ); + + // Add 5 records + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + clock.set_for_testing(initial_time_for_testing() + 1000); + + let mut i = 0u64; + while (i < 5) { + trail.add_record( + &record_cap, + new_test_data(i, b"Record"), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + i = i + 1; + }; + + clock::destroy_for_testing(clock); + transfer::public_transfer(record_cap, admin); + admin_cap.destroy_for_testing(); + ts::return_shared(trail); + }; + + // Test: Both locks satisfied - can delete + ts::next_tx(&mut scenario, admin); + { + let mut trail = ts::take_shared>(&scenario); + let record_cap = ts::take_from_sender(&scenario); + + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + + // 2 hours after creation - time lock expired + // Record 0 is not in last 2 - count lock satisfied + clock.set_for_testing(initial_time_for_testing() + 7200 * 1000); + + // Verify record 0 is unlocked (both conditions satisfied) + assert!(!trail.is_record_locked(0, &clock), 0); + assert!(trail.has_record(0), 1); + + // Delete should succeed + trail.delete_record(&record_cap, 0, &clock, ts::ctx(&mut scenario)); + + // Verify deletion + assert!(!trail.has_record(0), 2); + + clock::destroy_for_testing(clock); + record_cap.destroy_for_testing(); + ts::return_shared(trail); + }; + + ts::end(scenario); +} From d894813b0672fae1406cd95c3a46596c176a2aaf Mon Sep 17 00:00:00 2001 From: Yasir Date: Tue, 13 Jan 2026 15:35:12 +0300 Subject: [PATCH 030/189] fix: enhance documentation clarity in new_metadata function --- audit-trail-move/sources/audit_trail.move | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/audit-trail-move/sources/audit_trail.move b/audit-trail-move/sources/audit_trail.move index 1e047463..ae965de4 100644 --- a/audit-trail-move/sources/audit_trail.move +++ b/audit-trail-move/sources/audit_trail.move @@ -142,7 +142,7 @@ public fun new_metadata(name: Option, description: Option): Immu /// /// Returns /// ------- -/// * Capability with "Admin" role, allowing the creator to define custom +/// * Capability with *Admin* role, allowing the creator to define custom /// roles and issue capabilities to other users. /// * Trail ID public fun create( From 08966a0580767acf72a49b647d1accc038fe985c Mon Sep 17 00:00:00 2001 From: Yasir Date: Wed, 14 Jan 2026 09:43:30 +0300 Subject: [PATCH 031/189] refactor: update TrailImmutableMetadata structure and related functions to use non-optional name field; adjust tests for consistency --- audit-trail-move/sources/audit_trail.move | 23 ++++++++++--------- audit-trail-move/tests/capability_tests.move | 10 ++++---- .../tests/create_audit_trail_tests.move | 22 ++++++++---------- audit-trail-move/tests/test_utils.move | 10 ++++---- 4 files changed, 31 insertions(+), 34 deletions(-) diff --git a/audit-trail-move/sources/audit_trail.move b/audit-trail-move/sources/audit_trail.move index 0a29d60f..6b1d6ca8 100644 --- a/audit-trail-move/sources/audit_trail.move +++ b/audit-trail-move/sources/audit_trail.move @@ -46,7 +46,7 @@ const INITIAL_ADMIN_ROLE_NAME: vector = b"Admin"; /// Metadata set at trail creation (immutable) public struct TrailImmutableMetadata has copy, drop, store { - name: Option, + name: String, description: Option, } @@ -67,7 +67,7 @@ public struct AuditTrail has key, store { /// A list of role definitions consisting of a unique role specifier and a list of associated permissions roles: VecMap>, /// Set at creation, cannot be changed - immutable_metadata: TrailImmutableMetadata, + immutable_metadata: Option, /// Can be updated by holders of MetadataUpdate permission updatable_metadata: Option, /// Whitelist of all issued capability IDs @@ -110,10 +110,7 @@ public struct CapabilityIssued has copy, drop { // ===== Constructors ===== /// Create immutable trail metadata -public fun new_trail_metadata( - name: Option, - description: Option, -): TrailImmutableMetadata { +public fun new_trail_metadata(name: String, description: Option): TrailImmutableMetadata { TrailImmutableMetadata { name, description } } @@ -140,7 +137,7 @@ public fun create( initial_data: Option, initial_record_metadata: Option, locking_config: LockingConfig, - trail_metadata: TrailImmutableMetadata, + trail_metadata: Option, updatable_metadata: Option, clock: &Clock, ctx: &mut TxContext, @@ -346,13 +343,17 @@ public fun trail_id(trail: &AuditTrail): ID { } /// Get the trail name (immutable metadata) -public fun trail_name(trail: &AuditTrail): &Option { - &trail.immutable_metadata.name +public fun trail_name(trail: &AuditTrail): Option { + trail.immutable_metadata.map!(|metadata| metadata.name) } /// Get the trail description (immutable metadata) -public fun trail_description(trail: &AuditTrail): &Option { - &trail.immutable_metadata.description +public fun trail_description(trail: &AuditTrail): Option { + if (trail.immutable_metadata.is_some()) { + option::borrow(&trail.immutable_metadata).description + } else { + option::none() + } } /// Get the updatable metadata diff --git a/audit-trail-move/tests/capability_tests.move b/audit-trail-move/tests/capability_tests.move index 8fc81add..1c2a41dc 100644 --- a/audit-trail-move/tests/capability_tests.move +++ b/audit-trail-move/tests/capability_tests.move @@ -33,7 +33,7 @@ fun test_new_capability() { let (admin_cap, trail_id) = setup_test_audit_trail( &mut scenario, locking_config, - std::option::none(), + option::none(), ); transfer::public_transfer(admin_cap, admin_user); @@ -147,7 +147,7 @@ fun test_revoke_capability() { let (admin_cap, trail_id) = setup_test_audit_trail( &mut scenario, locking_config, - std::option::none(), + option::none(), ); transfer::public_transfer(admin_cap, admin_user); @@ -285,7 +285,7 @@ fun test_destroy_capability() { let (admin_cap, trail_id) = setup_test_audit_trail( &mut scenario, locking_config, - std::option::none(), + option::none(), ); transfer::public_transfer(admin_cap, admin_user); @@ -422,7 +422,7 @@ fun test_capability_lifecycle() { let (admin_cap, trail_id) = setup_test_audit_trail( &mut scenario, locking_config, - std::option::none(), + option::none(), ); transfer::public_transfer(admin_cap, admin_user); @@ -502,7 +502,7 @@ fun test_capability_lifecycle() { trail.add_record( &record_cap, test_data, - std::option::none(), + option::none(), &clock, ts::ctx(&mut scenario), ); diff --git a/audit-trail-move/tests/create_audit_trail_tests.move b/audit-trail-move/tests/create_audit_trail_tests.move index 6ced54ad..c74682fd 100644 --- a/audit-trail-move/tests/create_audit_trail_tests.move +++ b/audit-trail-move/tests/create_audit_trail_tests.move @@ -26,7 +26,7 @@ fun test_create_without_initial_record() { let (admin_cap, trail_id) = setup_test_audit_trail( &mut scenario, locking_config, - std::option::none(), + option::none(), ); // Verify capability was created @@ -111,17 +111,13 @@ fun test_create_minimal_metadata() { clock.set_for_testing(3000); let locking_config = locking::new(locking::window_count_based(0)); - let trail_metadata = main::new_trail_metadata( - std::option::none(), - std::option::none(), - ); let (admin_cap, _trail_id) = main::create( - std::option::none(), - std::option::none(), + option::none(), + option::none(), locking_config, - trail_metadata, - std::option::none(), + option::none(), + option::none(), &clock, ts::ctx(&mut scenario), ); @@ -163,7 +159,7 @@ fun test_create_with_locking_enabled() { let (admin_cap, _trail_id) = setup_test_audit_trail( &mut scenario, locking_config, - std::option::none(), + option::none(), ); // Clean up @@ -201,7 +197,7 @@ fun test_create_multiple_trails() { let (admin_cap1, trail_id1) = setup_test_audit_trail( &mut scenario, locking_config, - std::option::none(), + option::none(), ); trail_ids.push_back(trail_id1); @@ -216,7 +212,7 @@ fun test_create_multiple_trails() { let (admin_cap2, trail_id2) = setup_test_audit_trail( &mut scenario, locking_config, - std::option::none(), + option::none(), ); trail_ids.push_back(trail_id2); @@ -250,7 +246,7 @@ fun test_create_metadata_admin_role() { let (admin_cap, trail_id) = setup_test_audit_trail( &mut scenario, locking_config, - std::option::none(), + option::none(), ); // Verify admin capability was created diff --git a/audit-trail-move/tests/test_utils.move b/audit-trail-move/tests/test_utils.move index ee729f22..5a923af0 100644 --- a/audit-trail-move/tests/test_utils.move +++ b/audit-trail-move/tests/test_utils.move @@ -35,16 +35,16 @@ public(package) fun setup_test_audit_trail( clock.set_for_testing(INITIAL_TIME_FOR_TESTING); let trail_metadata = main::new_trail_metadata( - std::option::some(string::utf8(b"Setup Test Trail")), - std::option::none(), + string::utf8(b"Setup Test Trail"), + string::utf8(b"Setup Test Trail Description"), ); let (admin_cap, trail_id) = main::create( initial_data, - std::option::none(), + option::none(), locking_config, - trail_metadata, - std::option::none(), + option::some(trail_metadata), + option::none(), &clock, ts::ctx(scenario), ); From 06fac21d098dda6b49db3bcf7edfc7d5e153a562 Mon Sep 17 00:00:00 2001 From: Yasir Date: Wed, 14 Jan 2026 13:44:03 +0300 Subject: [PATCH 032/189] refactor: enhance documentation and add role management tests --- audit-trail-move/sources/audit_trail.move | 69 ++++++++-------- audit-trail-move/tests/role_tests.move | 97 +++++++++++++++++++++++ 2 files changed, 134 insertions(+), 32 deletions(-) diff --git a/audit-trail-move/sources/audit_trail.move b/audit-trail-move/sources/audit_trail.move index ae965de4..2197c775 100644 --- a/audit-trail-move/sources/audit_trail.move +++ b/audit-trail-move/sources/audit_trail.move @@ -2,11 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 /// Audit Trails with role-based access control and timelock -/// -/// An audit trail is a tamper-proof, sequential chain of notarized records where each entry -/// references its predecessor, ensuring verifiable continuity and integrity. -/// -/// Records are addressed by id + sequence_number +/// A trail is a tamper-proof, sequential chain of notarized records where each +/// entry references its predecessor, ensuring verifiable continuity and +/// integrity. module audit_trail::main; use audit_trail::{ @@ -46,33 +44,37 @@ const INITIAL_ADMIN_ROLE_NAME: vector = b"Admin"; // ===== Core Structures ===== -/// Metadata set at trail creation (immutable) +/// Metadata set at trail creation public struct ImmutableMetadata has copy, drop, store { name: Option, description: Option, } -/// Shared audit trail object with role-based access control -/// Records are stored in a LinkedTable and addressed by sequence number +/// A shared, tamper-evident ledger for storing sequential records with +/// role-based access control. +/// +/// It maintains an ordered sequence of records, each assigned a unique +/// auto-incrementing sequence number. +/// Uses capability-based RBAC to manage access to the trail and its records. public struct AuditTrail has key, store { id: UID, /// Address that created this trail creator: address, - /// Creation timestamp (milliseconds) + /// Creation timestamp in milliseconds created_at: u64, - /// Total records ever added (also serves as next sequence number) + /// Total records added (also next sequence number) record_count: u64, - /// Records stored by sequence number (0-indexed) + /// LinkedTable mapping sequence numbers to records records: LinkedTable>, /// Deletion locking rules locking_config: LockingConfig, - /// A list of role definitions consisting of a unique role specifier and a list of associated permissions + /// Map of role names to permission sets. roles: VecMap>, - /// Set at creation, cannot be changed + /// Fix name/description set at creation, cannot be changed immutable_metadata: ImmutableMetadata, - /// Can be updated by holders of MetadataUpdate permission + /// Mutable metadata (requires `MetadataUpdate` permission) updatable_metadata: Option, - /// Whitelist of all issued capability IDs + /// Whitelist of valid capability IDs issued_capabilities: VecSet, } @@ -93,7 +95,6 @@ public struct AuditTrailDeleted has copy, drop { } /// Emitted when a record is added to the trail -/// Records are identified by trail_id + sequence_number public struct RecordAdded has copy, drop { trail_id: ID, sequence_number: u64, @@ -168,7 +169,7 @@ public fun create( let record = record::new( initial_data.destroy_some(), initial_record_metadata, - 0, // sequence_number + 0, creator, timestamp, ); @@ -243,7 +244,7 @@ public fun add_record( let caller = ctx.sender(); let timestamp = clock::timestamp_ms(clock); - let trail_id = object::uid_to_inner(&trail.id); + let trail_id = trail.id(); let sequence_number = trail.record_count; let record = record::new( @@ -282,7 +283,7 @@ public fun delete_record( let caller = ctx.sender(); let timestamp = clock::timestamp_ms(clock); - let trail_id = object::uid_to_inner(&trail.id); + let trail_id = trail.id(); let record = linked_table::remove(&mut trail.records, sequence_number); record::destroy(record); @@ -297,7 +298,8 @@ public fun delete_record( // ===== Locking ===== -/// Check if a record is locked (cannot be deleted) +/// Check if a record is locked based on the trail's locking configuration. +/// Aborts with ERecordNotFound if the record doesn't exist. public fun is_record_locked( trail: &AuditTrail, sequence_number: u64, @@ -317,12 +319,12 @@ public fun is_record_locked( ) } -/// Update the locking configuration +/// Update the locking configuration. Requires `UpdateLockingConfig` permission. public fun update_locking_config( trail: &mut AuditTrail, cap: &Capability, new_config: LockingConfig, - _ctx: &mut TxContext, + _: &mut TxContext, ) { assert!( trail.has_capability_permission(cap, &permission::update_locking_config()), @@ -336,7 +338,7 @@ public fun update_locking_config_for_delete_record( trail: &mut AuditTrail, cap: &Capability, new_delete_record_lock: LockingWindow, - _ctx: &mut TxContext, + _: &mut TxContext, ) { assert!( trail.has_capability_permission( @@ -353,7 +355,7 @@ public fun update_metadata( trail: &mut AuditTrail, cap: &Capability, new_metadata: Option, - _ctx: &mut TxContext, + _: &mut TxContext, ) { assert!( trail.has_capability_permission(cap, &permission::update_metadata()), @@ -384,12 +386,12 @@ public fun id(trail: &AuditTrail): ID { object::uid_to_inner(&trail.id) } -/// Get the trail name (immutable metadata) +/// Get the trail name public fun name(trail: &AuditTrail): &Option { &trail.immutable_metadata.name } -/// Get the trail description (immutable metadata) +/// Get the trail description public fun description(trail: &AuditTrail): &Option { &trail.immutable_metadata.description } @@ -404,17 +406,17 @@ public fun locking_config(trail: &AuditTrail): &LockingConfi &trail.locking_config } -/// Check if the trail is empty (no records) +/// Check if the trail is empty public fun is_empty(trail: &AuditTrail): bool { linked_table::is_empty(&trail.records) } -/// Get the first sequence number (None if empty) +/// Get the first sequence number public fun first_sequence(trail: &AuditTrail): Option { *linked_table::front(&trail.records) } -/// Get the last sequence number (None if empty) +/// Get the last sequence number public fun last_sequence(trail: &AuditTrail): Option { *linked_table::back(&trail.records) } @@ -455,7 +457,7 @@ public fun create_role( cap: &Capability, role: String, permissions: VecSet, - _ctx: &mut TxContext, + _: &mut TxContext, ) { assert!(trail.has_capability_permission(cap, &permission::add_roles()), EPermissionDenied); vec_map::insert(&mut trail.roles, role, permissions); @@ -466,7 +468,7 @@ public fun delete_role( trail: &mut AuditTrail, cap: &Capability, role: &String, - _ctx: &mut TxContext, + _: &mut TxContext, ) { assert!(trail.has_capability_permission(cap, &permission::delete_roles()), EPermissionDenied); vec_map::remove(&mut trail.roles, role); @@ -478,10 +480,11 @@ public fun update_role_permissions( cap: &Capability, role: &String, new_permissions: VecSet, - _ctx: &mut TxContext, + _: &mut TxContext, ) { assert!(trail.has_capability_permission(cap, &permission::update_roles()), EPermissionDenied); assert!(vec_map::contains(&trail.roles, role), ERoleDoesNotExist); + vec_map::remove(&mut trail.roles, role); vec_map::insert(&mut trail.roles, *role, new_permissions); } @@ -545,6 +548,7 @@ public fun destroy_capability( cap_to_destroy.destroy(); } +/// Revoke a capability. Requires `CapabilitiesRevoke` permission. public fun revoke_capability( trail: &mut AuditTrail, cap: &Capability, @@ -557,6 +561,7 @@ public fun revoke_capability( trail.issued_capabilities.remove(&cap_to_revoke); } +/// Get the capabilities issued for this trail public fun issued_capabilities(trail: &AuditTrail): &VecSet { &trail.issued_capabilities } diff --git a/audit-trail-move/tests/role_tests.move b/audit-trail-move/tests/role_tests.move index 20c8f2c3..e90215e1 100644 --- a/audit-trail-move/tests/role_tests.move +++ b/audit-trail-move/tests/role_tests.move @@ -198,6 +198,52 @@ fun test_role_based_permission_delegation() { ts::end(scenario); } +#[test] +fun test_delete_role_success() { + let admin_user = @0xAD; + + let mut scenario = ts::begin(admin_user); + + { + let locking_config = locking::new(locking::window_count_based(0)); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::none(), + ); + transfer::public_transfer(admin_cap, admin_user); + }; + + ts::next_tx(&mut scenario, admin_user); + { + let admin_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + + // Verify initial state - only Admin role exists + assert!(trail.roles().size() == 1, 0); + + // Create a role to delete + let perms = permission::from_vec(vector[permission::add_record()]); + trail.create_role(&admin_cap, string::utf8(b"RoleToDelete"), perms, ts::ctx(&mut scenario)); + + // Verify the role was created + assert!(trail.roles().size() == 2, 1); + assert!(trail.has_role(&string::utf8(b"RoleToDelete")), 2); + + // Delete the role + trail.delete_role(&admin_cap, &string::utf8(b"RoleToDelete"), ts::ctx(&mut scenario)); + + // Verify the role was deleted + assert!(trail.roles().size() == 1, 3); + assert!(!trail.has_role(&string::utf8(b"RoleToDelete")), 4); + + ts::return_to_sender(&scenario, admin_cap); + ts::return_shared(trail); + }; + + ts::end(scenario); +} + // ===== Error Case Tests ===== #[test] @@ -425,6 +471,57 @@ fun test_get_role_permissions_nonexistent() { ts::end(scenario); } +#[test] +fun test_update_role_permissions_success() { + let admin_user = @0xAD; + + let mut scenario = ts::begin(admin_user); + + { + let locking_config = locking::new(locking::window_count_based(0)); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::none(), + ); + transfer::public_transfer(admin_cap, admin_user); + }; + + ts::next_tx(&mut scenario, admin_user); + { + let admin_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + + // Create a role with add_record permission + let initial_perms = permission::from_vec(vector[permission::add_record()]); + trail.create_role(&admin_cap, string::utf8(b"TestRole"), initial_perms, ts::ctx(&mut scenario)); + + // Verify the role was created with add_record permission + let perms = trail.get_role_permissions(&string::utf8(b"TestRole")); + assert!(perms.contains(&permission::add_record()), 0); + assert!(!perms.contains(&permission::delete_record()), 1); + + // Update the role to have delete_record permission instead + let new_perms = permission::from_vec(vector[permission::delete_record()]); + trail.update_role_permissions( + &admin_cap, + &string::utf8(b"TestRole"), + new_perms, + ts::ctx(&mut scenario), + ); + + // Verify the permissions were updated + let updated_perms = trail.get_role_permissions(&string::utf8(b"TestRole")); + assert!(!updated_perms.contains(&permission::add_record()), 2); + assert!(updated_perms.contains(&permission::delete_record()), 3); + + ts::return_to_sender(&scenario, admin_cap); + ts::return_shared(trail); + }; + + ts::end(scenario); +} + #[test] #[expected_failure(abort_code = main::ERoleDoesNotExist)] fun test_update_role_permissions_nonexistent() { From a2c80239044240d1057cd2a59af91a6a56a03904 Mon Sep 17 00:00:00 2001 From: Yasir Date: Thu, 15 Jan 2026 11:55:54 +0300 Subject: [PATCH 033/189] fix: add error handling for existing roles in role management functions --- audit-trail-move/sources/audit_trail.move | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/audit-trail-move/sources/audit_trail.move b/audit-trail-move/sources/audit_trail.move index da99e4a4..19df510c 100644 --- a/audit-trail-move/sources/audit_trail.move +++ b/audit-trail-move/sources/audit_trail.move @@ -28,6 +28,8 @@ const ERecordNotFound: vector = b"Record not found at the given sequence num #[error] const ERoleDoesNotExist: vector = b"The specified role does not exist in the `roles` map"; #[error] +const ERoleAlreadyExists: vector = b"The specified role already exists in the `roles` map"; +#[error] const EPermissionDenied: vector = b"The role associated with the provided capability does not have the required permission"; #[error] @@ -464,6 +466,7 @@ public fun create_role( _: &mut TxContext, ) { assert!(trail.has_capability_permission(cap, &permission::add_roles()), EPermissionDenied); + assert!(!vec_map::contains(&trail.roles, &role), ERoleAlreadyExists); vec_map::insert(&mut trail.roles, role, permissions); } @@ -475,6 +478,7 @@ public fun delete_role( _: &mut TxContext, ) { assert!(trail.has_capability_permission(cap, &permission::delete_roles()), EPermissionDenied); + assert!(vec_map::contains(&trail.roles, role), ERoleDoesNotExist); vec_map::remove(&mut trail.roles, role); } @@ -505,6 +509,8 @@ public fun has_role(trail: &AuditTrail, role: &String): bool // ===== Capability related Functions ===== /// Indicates if a provided capability has a specific permission. +/// +/// If the capability has been revoked or does not belong to this trail, it aborts. public fun has_capability_permission( trail: &AuditTrail, cap: &Capability, From a4491b1980e842f04f625d5801143843db63312a Mon Sep 17 00:00:00 2001 From: Yasir Date: Thu, 15 Jan 2026 12:50:53 +0300 Subject: [PATCH 034/189] refactor: rename record_count to sequence_number for clarity and update related tests --- audit-trail-move/sources/audit_trail.move | 26 +++++++++++------------ audit-trail-move/tests/record_tests.move | 5 +++-- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/audit-trail-move/sources/audit_trail.move b/audit-trail-move/sources/audit_trail.move index 19df510c..589eb2b9 100644 --- a/audit-trail-move/sources/audit_trail.move +++ b/audit-trail-move/sources/audit_trail.move @@ -64,8 +64,8 @@ public struct AuditTrail has key, store { creator: address, /// Creation timestamp in milliseconds created_at: u64, - /// Total records added (also next sequence number) - record_count: u64, + /// Monotonic counter for sequence assignment (never decrements) + sequence_number: u64, /// LinkedTable mapping sequence numbers to records records: LinkedTable>, /// Deletion locking rules @@ -164,7 +164,7 @@ public fun create( let trail_id = object::uid_to_inner(&trail_uid); let mut records = linked_table::new>(ctx); - let mut record_count = 0; + let mut sequence_number = 0; let has_initial_record = initial_data.is_some(); if (initial_data.is_some()) { @@ -177,7 +177,7 @@ public fun create( ); linked_table::push_back(&mut records, 0, record); - record_count = 1; + sequence_number = 1; event::emit(RecordAdded { trail_id, @@ -204,7 +204,7 @@ public fun create( id: trail_uid, creator, created_at: timestamp, - record_count, + sequence_number, records, locking_config, roles, @@ -247,22 +247,22 @@ public fun add_record( let caller = ctx.sender(); let timestamp = clock::timestamp_ms(clock); let trail_id = trail.id(); - let sequence_number = trail.record_count; + let seq = trail.sequence_number; let record = record::new( stored_data, record_metadata, - sequence_number, + seq, caller, timestamp, ); - linked_table::push_back(&mut trail.records, sequence_number, record); - trail.record_count = trail.record_count + 1; + linked_table::push_back(&mut trail.records, seq, record); + trail.sequence_number = trail.sequence_number + 1; event::emit(RecordAdded { trail_id, - sequence_number, + sequence_number: seq, added_by: caller, timestamp, }); @@ -316,7 +316,7 @@ public fun is_record_locked( &trail.locking_config, sequence_number, record::added_at(record), - trail.record_count, + trail.sequence_number, current_time, ) } @@ -368,9 +368,9 @@ public fun update_metadata( // ===== Trail Query Functions ===== -/// Get the total number of records in the trail +/// Get the total number of records currently in the trail public fun record_count(trail: &AuditTrail): u64 { - trail.record_count + linked_table::length(&trail.records) } /// Get the trail creator address diff --git a/audit-trail-move/tests/record_tests.move b/audit-trail-move/tests/record_tests.move index 02e692ad..1c1b3369 100644 --- a/audit-trail-move/tests/record_tests.move +++ b/audit-trail-move/tests/record_tests.move @@ -294,8 +294,9 @@ fun test_delete_record_success() { trail.delete_record(&record_cap, 0, &clock, ts::ctx(&mut scenario)); // Verify record was deleted - assert!(trail.record_count() == 1, 2); // record_count doesn't decrease - assert!(!trail.has_record(0), 3); // but record is gone + assert!(trail.record_count() == 0, 2); // actual count decreases + assert!(trail.sequence_number() == 1, 3); // sequence stays monotonic + assert!(!trail.has_record(0), 4); // record is gone clock::destroy_for_testing(clock); ts::return_to_sender(&scenario, record_cap); From 84e4e82653e9e7337345ded31058bcf13a3fecd0 Mon Sep 17 00:00:00 2001 From: chrisgitiota Date: Fri, 16 Jan 2026 11:25:52 +0100 Subject: [PATCH 035/189] All existing tests migrated to the latest RoleMap interface --- audit-trail-move/sources/audit_trail.move | 17 +- audit-trail-move/sources/role_map.move | 58 +-- audit-trail-move/tests/capability_tests.move | 272 +++++++------ audit-trail-move/tests/locking_tests.move | 385 +++++++++++-------- audit-trail-move/tests/metadata_tests.move | 160 ++++---- audit-trail-move/tests/record_tests.move | 372 +++++++++--------- audit-trail-move/tests/role_tests.move | 269 ++++++++----- audit-trail-move/tests/test_utils.move | 5 + 8 files changed, 846 insertions(+), 692 deletions(-) diff --git a/audit-trail-move/sources/audit_trail.move b/audit-trail-move/sources/audit_trail.move index 7a80d823..4bcf6f08 100644 --- a/audit-trail-move/sources/audit_trail.move +++ b/audit-trail-move/sources/audit_trail.move @@ -24,9 +24,6 @@ const ERecordNotFound: vector = b"Record not found at the given sequence num const EPermissionDenied: vector = b"The role associated with the provided capability does not have the required permission"; #[error] -const ETrailIdNotCorrect: vector = - b"The trail ID associated with the provided capability does not match the audit trail"; -##[error] const ERecordLocked: vector = b"The record is locked and cannot be deleted"; // ===== Constants ===== @@ -273,7 +270,17 @@ public fun delete_record( clock: &Clock, ctx: &mut TxContext, ) { - assert!(trail.has_capability_permission(cap, &permission::delete_record()), EPermissionDenied); + assert!( + trail + .roles + .is_capability_valid( + cap, + &permission::delete_record(), + clock, + ctx, + ), + EPermissionDenied, + ); assert!(linked_table::contains(&trail.records, sequence_number), ERecordNotFound); assert!(!trail.is_record_locked(sequence_number, clock), ERecordLocked); @@ -467,6 +474,6 @@ public fun roles(trail: &AuditTrail): &RoleMap { } /// Returns a mutable reference to the RoleMap managing the roles and capabilities used in the audit trail -public fun trail_roles_mut(trail: &mut AuditTrail): &mut RoleMap { +public fun roles_mut(trail: &mut AuditTrail): &mut RoleMap { &mut trail.roles } diff --git a/audit-trail-move/sources/role_map.move b/audit-trail-move/sources/role_map.move index 07f17eec..5416969b 100644 --- a/audit-trail-move/sources/role_map.move +++ b/audit-trail-move/sources/role_map.move @@ -190,16 +190,13 @@ public fun new( /// Get the permissions associated with a specific role. /// Aborts with ERoleDoesNotExist if the role does not exist. -public fun rmap_get_role_permissions( - role_map: &RoleMap

, - role: &String, -): &VecSet

{ +public fun get_role_permissions(role_map: &RoleMap

, role: &String): &VecSet

{ assert!(vec_map::contains(&role_map.roles, role), ERoleDoesNotExist); vec_map::get(&role_map.roles, role) } /// Create a new role consisting of a role name and associated permissions -public fun rmap_create_role( +public fun create_role( role_map: &mut RoleMap

, cap: &Capability, role: String, @@ -221,7 +218,7 @@ public fun rmap_create_role( } /// Delete an existing role -public fun rmap_delete_role( +public fun delete_role( role_map: &mut RoleMap

, cap: &Capability, role: &String, @@ -242,7 +239,7 @@ public fun rmap_delete_role( } /// Update permissions associated with an existing role -public fun rmap_update_role_permissions( +public fun update_role_permissions( role_map: &mut RoleMap

, cap: &Capability, role: &String, @@ -261,11 +258,12 @@ public fun rmap_update_role_permissions( ); assert!(vec_map::contains(&role_map.roles, role), ERoleDoesNotExist); + vec_map::remove(&mut role_map.roles, role); vec_map::insert(&mut role_map.roles, *role, new_permissions); } /// Indicates if the specified role exists in the role_map -public fun rmap_has_role(role_map: &RoleMap

, role: &String): bool { +public fun has_role(role_map: &RoleMap

, role: &String): bool { vec_map::contains(&role_map.roles, role) } @@ -298,7 +296,7 @@ public fun rmap_has_role(role_map: &RoleMap

, role: &String): /// Returns /// ------- /// - bool: true if the capability is valid, otherwise aborts with the relevant error. -public fun rmap_is_capability_valid( +public fun is_capability_valid( role_map: &RoleMap

, cap: &Capability, permission: &P, @@ -349,7 +347,7 @@ public fun rmap_is_capability_valid( /// - Aborts with EPermissionDenied if the provided capability does not have the permission specified with `CapabilityAdminPermissions::add`. /// - Aborts with ERoleDoesNotExist if the specified role does not exist in the role_map. /// - Aborts with audit_trail::capability::EValidityPeriodInconsistent if the provided valid_from and valid_until are inconsistent. -public fun rmap_new_capability( +public fun new_capability( role_map: &mut RoleMap

, cap: &Capability, role: &String, @@ -392,7 +390,7 @@ public fun rmap_new_capability( /// Errors: /// - Aborts with EPermissionDenied if the provided capability does not have the permission specified with `CapabilityAdminPermissions::add`. /// - Aborts with ERoleDoesNotExist if the specified role does not exist in the role_map. -public fun rmap_new_capability_without_restrictions( +public fun new_capability_without_restrictions( role_map: &mut RoleMap

, cap: &Capability, role: &String, @@ -429,7 +427,7 @@ public fun rmap_new_capability_without_restrictions( /// Errors: /// - Aborts with EPermissionDenied if the provided capability does not have the permission specified with `CapabilityAdminPermissions::add`. /// - Aborts with ERoleDoesNotExist if the specified role does not exist in the role_map. -public fun rmap_new_capability_valid_until( +public fun new_capability_valid_until( role_map: &mut RoleMap

, cap: &Capability, role: &String, @@ -469,7 +467,7 @@ public fun rmap_new_capability_valid_until( /// Errors: /// - Aborts with EPermissionDenied if the provided capability does not have the permission specified with `CapabilityAdminPermissions::add`. /// - Aborts with ERoleDoesNotExist if the specified role does not exist in the role_map. -public fun rmap_new_capability_for_address( +public fun new_capability_for_address( role_map: &mut RoleMap

, cap: &Capability, role: &String, @@ -509,7 +507,7 @@ public fun rmap_new_capability_for_address( /// TODO: Clarify if we need to restrict access with the `CapabilitiesRevoke` permission here. /// If yes, we also need a destroy function for Admin capabilities (without the need of another Admin capability). /// Otherwise the last Admin capability holder will block the role_map forever by not being able to destroy it. -public fun rmap_destroy_capability( +public fun destroy_capability( role_map: &mut RoleMap

, cap_to_destroy: Capability, ) { @@ -542,7 +540,7 @@ public fun rmap_destroy_capability( /// Errors: /// - Aborts with EPermissionDenied if the provided capability does not have the permission specified with `CapabilityAdminPermissions::revoke`. /// - Aborts with ERoleDoesNotExist if the specified role does not exist in the `RoleMap.issued_capabilities()` list. -public fun rmap_revoke_capability( +public fun revoke_capability( role_map: &mut RoleMap

, cap: &Capability, cap_to_revoke: ID, @@ -584,42 +582,20 @@ fun register_new_capability(role_map: &mut RoleMap

, new_cap: // =============== Getter Functions ================================================ /// Returns the size of the role_map, the number of managed roles -public fun rmap_size(role_map: &RoleMap

): u64 { +public fun size(role_map: &RoleMap

): u64 { vec_map::size(&role_map.roles) } /// Returns the security_vault_id associated with the role_map -public fun rmap_security_vault_id(role_map: &RoleMap

): ID { +public fun security_vault_id(role_map: &RoleMap

): ID { role_map.security_vault_id } //Returns the role admin permissions associated with the role_map -public fun rmap_role_admin_permissions( - role_map: &RoleMap

, -): &RoleAdminPermissions

{ +public fun role_admin_permissions(role_map: &RoleMap

): &RoleAdminPermissions

{ &role_map.role_admin_permissions } -public fun rmap_issued_capabilities(role_map: &RoleMap

): &VecSet { +public fun issued_capabilities(role_map: &RoleMap

): &VecSet { &role_map.issued_capabilities } - -// =============== public use statements =========================================== - -public use fun rmap_get_role_permissions as RoleMap.get_role_permissions; -public use fun rmap_create_role as RoleMap.create_role; -public use fun rmap_delete_role as RoleMap.delete_role; -public use fun rmap_update_role_permissions as RoleMap.update_role_permissions; -public use fun rmap_has_role as RoleMap.has_role; -public use fun rmap_size as RoleMap.size; -public use fun rmap_security_vault_id as RoleMap.security_vault_id; -public use fun rmap_role_admin_permissions as RoleMap.role_admin_permissions; -public use fun rmap_is_capability_valid as RoleMap.is_capability_valid; -public use fun rmap_new_capability as RoleMap.new_capability; -public use fun rmap_new_capability_without_restrictions as - RoleMap.new_capability_without_restrictions; -public use fun rmap_new_capability_valid_until as RoleMap.new_capability_valid_until; -public use fun rmap_new_capability_for_address as RoleMap.new_capability_for_address; -public use fun rmap_destroy_capability as RoleMap.destroy_capability; -public use fun rmap_revoke_capability as RoleMap.revoke_capability; -public use fun rmap_issued_capabilities as RoleMap.issued_capabilities; diff --git a/audit-trail-move/tests/capability_tests.move b/audit-trail-move/tests/capability_tests.move index 4281291b..9631539e 100644 --- a/audit-trail-move/tests/capability_tests.move +++ b/audit-trail-move/tests/capability_tests.move @@ -4,7 +4,7 @@ module audit_trail::capability_tests; use audit_trail::{ capability::Capability, locking, - main::{Self, AuditTrail}, + main::AuditTrail, permission, test_utils::{ Self, @@ -108,14 +108,12 @@ fun test_new_capability() { let user1 = @0xB0B; let user2 = @0xCAB; - transfer::public_transfer(cap, record_user); - cleanup_capability_trail_and_clock(scenario, admin_cap, trail, clock); - }; + let mut scenario = ts::begin(admin_user); let trail_id = { let locking_config = locking::new(locking::window_count_based(0)); let (admin_cap, trail_id) = setup_test_audit_trail( - scenario, + &mut scenario, locking_config, option::none(), ); @@ -126,20 +124,19 @@ fun test_new_capability() { // Create a role to issue capabilities for ts::next_tx(&mut scenario, admin_user); { - let admin_cap = ts::take_from_sender(scenario); - let mut trail = ts::take_shared>(scenario); - let clock = iota::clock::create_for_testing(ts::ctx(scenario)); + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); - trail.create_role( - &admin_cap, - string::utf8(b"RecordAdmin"), - permission::record_admin_permissions(), - ts::ctx(&mut scenario), - ); + trail + .roles_mut() + .create_role( + &admin_cap, + string::utf8(b"RecordAdmin"), + permission::record_admin_permissions(), + &clock, + ts::ctx(&mut scenario), + ); - iota::clock::destroy_for_testing(clock); - ts::return_to_sender(scenario, admin_cap); - ts::return_shared(trail); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); }; // Issue first capability and verify it's tracked @@ -147,7 +144,6 @@ fun test_new_capability() { let cap1_id = { let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); - let initial_cap_count = trail.issued_capabilities().size(); // Verify initial state - only admin capability should be tracked let initial_cap_count = trail.roles().issued_capabilities().size(); assert!(initial_cap_count == 1, 0); // Only admin cap @@ -615,10 +611,31 @@ fun test_capability_issued_to_only() { ts::return_shared(trail); }; + // Unauthorized user cannot use the capability + ts::next_tx(&mut scenario, unauthorized_user); + { + let (record_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + // This should fail as unauthorized_user has the wrong address + let test_data = test_utils::new_test_data(1, b"Unauthorized record"); + trail.add_record( + &record_cap, + test_data, + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + cleanup_capability_trail_and_clock(&scenario, record_cap, trail, clock); + }; + + ts::end(scenario); +} + // ===== Error Case Tests ===== #[test] -#[expected_failure(abort_code = main::ECapabilityHasBeenRevoked)] +#[expected_failure(abort_code = audit_trail::role_map::ECapabilityHasBeenRevoked)] fun test_revoked_capability_cannot_be_used() { let admin_user = @0xAD; let user = @0xB0B; @@ -640,43 +657,47 @@ fun test_revoked_capability_cannot_be_used() { { let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); - trail.create_role( - &admin_cap, - string::utf8(b"RecordAdmin"), - permission::record_admin_permissions(), - ts::ctx(&mut scenario), - ); + trail + .roles_mut() + .create_role( + &admin_cap, + string::utf8(b"RecordAdmin"), + permission::record_admin_permissions(), + &clock, + ts::ctx(&mut scenario), + ); - let user_cap = trail.new_capability( - &admin_cap, - &string::utf8(b"RecordAdmin"), - ts::ctx(&mut scenario), - ); + let user_cap = trail + .roles_mut() + .new_capability_without_restrictions( + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); transfer::public_transfer(user_cap, user); - ts::return_to_sender(&scenario, admin_cap); - ts::return_shared(trail); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); }; // Revoke the capability ts::next_tx(&mut scenario, admin_user); { - let admin_cap = ts::take_from_sender(&scenario); - let mut trail = ts::take_shared>(&scenario); + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); let user_cap = ts::take_from_address(&scenario, user); - trail.revoke_capability(&admin_cap, user_cap.id()); + trail + .roles_mut() + .revoke_capability(&admin_cap, user_cap.id(), &clock, ts::ctx(&mut scenario)); ts::return_to_address(user, user_cap); - ts::return_to_sender(&scenario, admin_cap); - ts::return_shared(trail); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); }; // Try to use revoked capability - should fail ts::next_tx(&mut scenario, user); { - let mut trail = ts::take_shared>(&scenario); - let user_cap = ts::take_from_sender(&scenario); + let (user_cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); clock.set_for_testing(test_utils::initial_time_for_testing() + 1000); @@ -688,16 +709,14 @@ fun test_revoked_capability_cannot_be_used() { ts::ctx(&mut scenario), ); - iota::clock::destroy_for_testing(clock); - ts::return_to_sender(&scenario, user_cap); - ts::return_shared(trail); + cleanup_capability_trail_and_clock(&scenario, user_cap, trail, clock); }; ts::end(scenario); } #[test] -#[expected_failure(abort_code = main::ERoleDoesNotExist)] +#[expected_failure(abort_code = audit_trail::role_map::ERoleDoesNotExist)] fun test_new_capability_for_nonexistent_role() { let admin_user = @0xAD; @@ -715,25 +734,26 @@ fun test_new_capability_for_nonexistent_role() { ts::next_tx(&mut scenario, admin_user); { - let admin_cap = ts::take_from_sender(&scenario); - let mut trail = ts::take_shared>(&scenario); + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); - let bad_cap = trail.new_capability( - &admin_cap, - &string::utf8(b"NonExistentRole"), - ts::ctx(&mut scenario), - ); + let bad_cap = trail + .roles_mut() + .new_capability_without_restrictions( + &admin_cap, + &string::utf8(b"NonExistentRole"), + &clock, + ts::ctx(&mut scenario), + ); bad_cap.destroy_for_testing(); - ts::return_to_sender(&scenario, admin_cap); - ts::return_shared(trail); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); }; ts::end(scenario); } #[test] -#[expected_failure(abort_code = main::EPermissionDenied)] +#[expected_failure(abort_code = audit_trail::role_map::ECapabilityPermissionDenied)] fun test_revoke_capability_permission_denied() { let admin_user = @0xAD; let user1 = @0xB0B; @@ -754,35 +774,50 @@ fun test_revoke_capability_permission_denied() { // Create two roles: one without revoke permission, one with record permissions ts::next_tx(&mut scenario, admin_user); { - let admin_cap = ts::take_from_sender(&scenario); - let mut trail = ts::take_shared>(&scenario); + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); let perms = permission::from_vec(vector[permission::add_record()]); - trail.create_role(&admin_cap, string::utf8(b"NoRevokePerm"), perms, ts::ctx(&mut scenario)); + trail + .roles_mut() + .create_role( + &admin_cap, + string::utf8(b"NoRevokePerm"), + perms, + &clock, + ts::ctx(&mut scenario), + ); - trail.create_role( - &admin_cap, - string::utf8(b"RecordAdmin"), - permission::record_admin_permissions(), - ts::ctx(&mut scenario), - ); + trail + .roles_mut() + .create_role( + &admin_cap, + string::utf8(b"RecordAdmin"), + permission::record_admin_permissions(), + &clock, + ts::ctx(&mut scenario), + ); - let user1_cap = trail.new_capability( - &admin_cap, - &string::utf8(b"NoRevokePerm"), - ts::ctx(&mut scenario), - ); + let user1_cap = trail + .roles_mut() + .new_capability_without_restrictions( + &admin_cap, + &string::utf8(b"NoRevokePerm"), + &clock, + ts::ctx(&mut scenario), + ); - let user2_cap = trail.new_capability( - &admin_cap, - &string::utf8(b"RecordAdmin"), - ts::ctx(&mut scenario), - ); + let user2_cap = trail + .roles_mut() + .new_capability_without_restrictions( + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); transfer::public_transfer(user1_cap, user1); transfer::public_transfer(user2_cap, user2); - ts::return_to_sender(&scenario, admin_cap); - ts::return_shared(trail); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); }; // User1 (without revoke permission) tries to revoke User2's capability @@ -791,19 +826,23 @@ fun test_revoke_capability_permission_denied() { let user1_cap = ts::take_from_sender(&scenario); let mut trail = ts::take_shared>(&scenario); let user2_cap = ts::take_from_address(&scenario, user2); + let clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); - trail.revoke_capability(&user1_cap, user2_cap.id()); + trail + .roles_mut() + .revoke_capability(&user1_cap, user2_cap.id(), &clock, ts::ctx(&mut scenario)); ts::return_to_address(user2, user2_cap); ts::return_to_sender(&scenario, user1_cap); ts::return_shared(trail); + iota::clock::destroy_for_testing(clock); }; ts::end(scenario); } #[test] -#[expected_failure(abort_code = main::EPermissionDenied)] +#[expected_failure(abort_code = audit_trail::role_map::ECapabilityPermissionDenied)] fun test_new_capability_permission_denied() { let admin_user = @0xAD; let user = @0xB0B; @@ -823,63 +862,58 @@ fun test_new_capability_permission_denied() { // Create role without add_capabilities permission ts::next_tx(&mut scenario, admin_user); { - let admin_cap = ts::take_from_sender(&scenario); - let mut trail = ts::take_shared>(&scenario); + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); let perms = permission::from_vec(vector[permission::add_record()]); - trail.create_role(&admin_cap, string::utf8(b"NoCapPerm"), perms, ts::ctx(&mut scenario)); + trail + .roles_mut() + .create_role( + &admin_cap, + string::utf8(b"NoCapPerm"), + perms, + &clock, + ts::ctx(&mut scenario), + ); - trail.create_role( - &admin_cap, - string::utf8(b"RecordAdmin"), - permission::record_admin_permissions(), - ts::ctx(&mut scenario), - ); + trail + .roles_mut() + .create_role( + &admin_cap, + string::utf8(b"RecordAdmin"), + permission::record_admin_permissions(), + &clock, + ts::ctx(&mut scenario), + ); - let user_cap = trail.new_capability( - &admin_cap, - &string::utf8(b"NoCapPerm"), - ts::ctx(&mut scenario), - ); + let user_cap = trail + .roles_mut() + .new_capability_without_restrictions( + &admin_cap, + &string::utf8(b"NoCapPerm"), + &clock, + ts::ctx(&mut scenario), + ); transfer::public_transfer(user_cap, user); - ts::return_to_sender(&scenario, admin_cap); - ts::return_shared(trail); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); }; // User tries to issue a new capability without permission ts::next_tx(&mut scenario, user); { - let user_cap = ts::take_from_sender(&scenario); - let mut trail = ts::take_shared>(&scenario); + let (user_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); - let new_cap = trail.new_capability( - &user_cap, - &string::utf8(b"RecordAdmin"), - ts::ctx(&mut scenario), - ); + let new_cap = trail + .roles_mut() + .new_capability_without_restrictions( + &user_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); new_cap.destroy_for_testing(); - ts::return_to_sender(&scenario, user_cap); - ts::return_shared(trail); - }; - - // Unauthorized user cannot use the capability - ts::next_tx(&mut scenario, unauthorized_user); - { - let (record_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); - - // This should fail as unauthorized_user has the wrong address - let test_data = test_utils::new_test_data(1, b"Unauthorized record"); - trail.add_record( - &record_cap, - test_data, - std::option::none(), - &clock, - ts::ctx(&mut scenario), - ); - - cleanup_capability_trail_and_clock(&scenario, record_cap, trail, clock); + cleanup_capability_trail_and_clock(&scenario, user_cap, trail, clock); }; ts::end(scenario); diff --git a/audit-trail-move/tests/locking_tests.move b/audit-trail-move/tests/locking_tests.move index 1f9ca1bb..12fcc336 100644 --- a/audit-trail-move/tests/locking_tests.move +++ b/audit-trail-move/tests/locking_tests.move @@ -4,9 +4,17 @@ module audit_trail::locking_tests; use audit_trail::{ capability::Capability, locking, - main::{Self, AuditTrail}, + main::AuditTrail, permission, - test_utils::{TestData, setup_test_audit_trail, new_test_data, initial_time_for_testing} + test_utils::{ + TestData, + setup_test_audit_trail, + new_test_data, + initial_time_for_testing, + fetch_capability_trail_and_clock, + cleanup_capability_trail_and_clock, + cleanup_trail_and_clock + } }; use iota::{clock, test_scenario as ts}; use std::string; @@ -110,25 +118,30 @@ fun test_count_based_locking() { // Create RecordAdmin role and capability ts::next_tx(&mut scenario, admin); { - let admin_cap = ts::take_from_sender(&scenario); - let mut trail = ts::take_shared>(&scenario); + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); - trail.create_role( - &admin_cap, - string::utf8(b"RecordAdmin"), - permission::record_admin_permissions(), - ts::ctx(&mut scenario), - ); + trail + .roles_mut() + .create_role( + &admin_cap, + string::utf8(b"RecordAdmin"), + permission::record_admin_permissions(), + &clock, + ts::ctx(&mut scenario), + ); - let record_cap = trail.new_capability( - &admin_cap, - &string::utf8(b"RecordAdmin"), - ts::ctx(&mut scenario), - ); + let record_cap = trail + .roles_mut() + .new_capability_without_restrictions( + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); transfer::public_transfer(record_cap, admin); admin_cap.destroy_for_testing(); - ts::return_shared(trail); + cleanup_trail_and_clock(trail, clock); }; // Add 5 records and verify locking @@ -254,30 +267,37 @@ fun test_update_locking_config() { // Create LockingAdmin role ts::next_tx(&mut scenario, admin); { - let admin_cap = ts::take_from_sender(&scenario); - let mut trail = ts::take_shared>(&scenario); + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); let perms = permission::from_vec(vector[permission::update_locking_config()]); - trail.create_role(&admin_cap, string::utf8(b"LockingAdmin"), perms, ts::ctx(&mut scenario)); + trail + .roles_mut() + .create_role( + &admin_cap, + string::utf8(b"LockingAdmin"), + perms, + &clock, + ts::ctx(&mut scenario), + ); - let locking_cap = trail.new_capability( - &admin_cap, - &string::utf8(b"LockingAdmin"), - ts::ctx(&mut scenario), - ); + let locking_cap = trail + .roles_mut() + .new_capability_without_restrictions( + &admin_cap, + &string::utf8(b"LockingAdmin"), + &clock, + ts::ctx(&mut scenario), + ); transfer::public_transfer(locking_cap, admin); admin_cap.destroy_for_testing(); - ts::return_shared(trail); + cleanup_trail_and_clock(trail, clock); }; // Update from no-locking to time-based ts::next_tx(&mut scenario, admin); { - let locking_cap = ts::take_from_sender(&scenario); - let mut trail = ts::take_shared>(&scenario); - - let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + let (locking_cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); clock.set_for_testing(initial_time_for_testing() + 1000); // Initially unlocked @@ -287,22 +307,22 @@ fun test_update_locking_config() { trail.update_locking_config( &locking_cap, locking::time_based(3600), + &clock, ts::ctx(&mut scenario), ); // Now locked assert!(trail.is_record_locked(0, &clock), 1); - clock::destroy_for_testing(clock); - locking_cap.destroy_for_testing(); - ts::return_shared(trail); + // locking_cap.destroy_for_testing(); + cleanup_capability_trail_and_clock(&scenario, locking_cap, trail, clock); }; ts::end(scenario); } #[test] -#[expected_failure(abort_code = main::EPermissionDenied)] +#[expected_failure(abort_code = audit_trail::role_map::ECapabilityPermissionDenied)] fun test_update_locking_config_permission_denied() { let admin = @0xAD; let mut scenario = ts::begin(admin); @@ -320,42 +340,45 @@ fun test_update_locking_config_permission_denied() { // Create role WITHOUT UpdateLockingConfig permission ts::next_tx(&mut scenario, admin); { - let admin_cap = ts::take_from_sender(&scenario); - let mut trail = ts::take_shared>(&scenario); + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); let perms = permission::from_vec(vector[permission::add_record()]); - trail.create_role( - &admin_cap, - string::utf8(b"NoLockingPerm"), - perms, - ts::ctx(&mut scenario), - ); - - let no_locking_cap = trail.new_capability( - &admin_cap, - &string::utf8(b"NoLockingPerm"), - ts::ctx(&mut scenario), - ); + trail + .roles_mut() + .create_role( + &admin_cap, + string::utf8(b"NoLockingPerm"), + perms, + &clock, + ts::ctx(&mut scenario), + ); + let no_locking_cap = trail + .roles_mut() + .new_capability_without_restrictions( + &admin_cap, + &string::utf8(b"NoLockingPerm"), + &clock, + ts::ctx(&mut scenario), + ); transfer::public_transfer(no_locking_cap, admin); admin_cap.destroy_for_testing(); - ts::return_shared(trail); + cleanup_trail_and_clock(trail, clock); }; // Try to update locking config - should fail ts::next_tx(&mut scenario, admin); { - let no_locking_cap = ts::take_from_sender(&scenario); - let mut trail = ts::take_shared>(&scenario); + let (no_locking_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); trail.update_locking_config( &no_locking_cap, locking::time_based(3600), + &clock, ts::ctx(&mut scenario), ); - no_locking_cap.destroy_for_testing(); - ts::return_shared(trail); + cleanup_capability_trail_and_clock(&scenario, no_locking_cap, trail, clock); }; ts::end(scenario); @@ -380,37 +403,41 @@ fun test_update_locking_config_for_delete_record() { // Create role with UpdateLockingConfigForDeleteRecord permission ts::next_tx(&mut scenario, admin); { - let admin_cap = ts::take_from_sender(&scenario); - let mut trail = ts::take_shared>(&scenario); + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); let perms = permission::from_vec(vector[ permission::update_locking_config_for_delete_record(), ]); - trail.create_role( - &admin_cap, - string::utf8(b"DeleteLockAdmin"), - perms, - ts::ctx(&mut scenario), - ); + trail + .roles_mut() + .create_role( + &admin_cap, + string::utf8(b"DeleteLockAdmin"), + perms, + &clock, + ts::ctx(&mut scenario), + ); - let delete_lock_cap = trail.new_capability( - &admin_cap, - &string::utf8(b"DeleteLockAdmin"), - ts::ctx(&mut scenario), - ); + let delete_lock_cap = trail + .roles_mut() + .new_capability_without_restrictions( + &admin_cap, + &string::utf8(b"DeleteLockAdmin"), + &clock, + ts::ctx(&mut scenario), + ); transfer::public_transfer(delete_lock_cap, admin); admin_cap.destroy_for_testing(); - ts::return_shared(trail); + cleanup_trail_and_clock(trail, clock); }; // Update delete_record_lock ts::next_tx(&mut scenario, admin); { - let delete_lock_cap = ts::take_from_sender(&scenario); - let mut trail = ts::take_shared>(&scenario); - - let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + let (delete_lock_cap, mut trail, mut clock) = fetch_capability_trail_and_clock( + &mut scenario, + ); clock.set_for_testing(initial_time_for_testing() + 1000); // Initially unlocked @@ -420,22 +447,21 @@ fun test_update_locking_config_for_delete_record() { trail.update_locking_config_for_delete_record( &delete_lock_cap, locking::window_count_based(5), + &clock, ts::ctx(&mut scenario), ); // Now locked (single record, last 5 are locked) assert!(trail.is_record_locked(0, &clock), 1); - clock::destroy_for_testing(clock); - delete_lock_cap.destroy_for_testing(); - ts::return_shared(trail); + cleanup_capability_trail_and_clock(&scenario, delete_lock_cap, trail, clock); }; ts::end(scenario); } #[test] -#[expected_failure(abort_code = main::EPermissionDenied)] +#[expected_failure(abort_code = audit_trail::role_map::ECapabilityPermissionDenied)] fun test_update_locking_config_for_delete_record_permission_denied() { let admin = @0xAD; let mut scenario = ts::begin(admin); @@ -453,37 +479,46 @@ fun test_update_locking_config_for_delete_record_permission_denied() { // Create role with update_locking_config but NOT update_locking_config_for_delete_record ts::next_tx(&mut scenario, admin); { - let admin_cap = ts::take_from_sender(&scenario); - let mut trail = ts::take_shared>(&scenario); + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); let perms = permission::from_vec(vector[permission::update_locking_config()]); - trail.create_role(&admin_cap, string::utf8(b"WrongPerm"), perms, ts::ctx(&mut scenario)); + trail + .roles_mut() + .create_role( + &admin_cap, + string::utf8(b"WrongPerm"), + perms, + &clock, + ts::ctx(&mut scenario), + ); - let wrong_cap = trail.new_capability( - &admin_cap, - &string::utf8(b"WrongPerm"), - ts::ctx(&mut scenario), - ); + let wrong_cap = trail + .roles_mut() + .new_capability_without_restrictions( + &admin_cap, + &string::utf8(b"WrongPerm"), + &clock, + ts::ctx(&mut scenario), + ); transfer::public_transfer(wrong_cap, admin); admin_cap.destroy_for_testing(); - ts::return_shared(trail); + cleanup_trail_and_clock(trail, clock); }; // Try to update delete_record_lock - should fail ts::next_tx(&mut scenario, admin); { - let wrong_cap = ts::take_from_sender(&scenario); - let mut trail = ts::take_shared>(&scenario); + let (wrong_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); trail.update_locking_config_for_delete_record( &wrong_cap, locking::window_count_based(5), + &clock, ts::ctx(&mut scenario), ); - wrong_cap.destroy_for_testing(); - ts::return_shared(trail); + cleanup_capability_trail_and_clock(&scenario, wrong_cap, trail, clock); }; ts::end(scenario); @@ -508,25 +543,30 @@ fun test_delete_record_after_time_lock_expires() { // Create RecordAdmin role ts::next_tx(&mut scenario, admin); { - let admin_cap = ts::take_from_sender(&scenario); - let mut trail = ts::take_shared>(&scenario); + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); - trail.create_role( - &admin_cap, - string::utf8(b"RecordAdmin"), - permission::record_admin_permissions(), - ts::ctx(&mut scenario), - ); + trail + .roles_mut() + .create_role( + &admin_cap, + string::utf8(b"RecordAdmin"), + permission::record_admin_permissions(), + &clock, + ts::ctx(&mut scenario), + ); - let record_cap = trail.new_capability( - &admin_cap, - &string::utf8(b"RecordAdmin"), - ts::ctx(&mut scenario), - ); + let record_cap = trail + .roles_mut() + .new_capability_without_restrictions( + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); transfer::public_transfer(record_cap, admin); admin_cap.destroy_for_testing(); - ts::return_shared(trail); + cleanup_trail_and_clock(trail, clock); }; // Test boundary: exactly at lock expiry (should still be locked) @@ -628,24 +668,28 @@ fun test_combined_time_and_count_locking_both_lock() { // Create RecordAdmin role and add records ts::next_tx(&mut scenario, admin); { - let admin_cap = ts::take_from_sender(&scenario); - let mut trail = ts::take_shared>(&scenario); + let (admin_cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); - trail.create_role( - &admin_cap, - string::utf8(b"RecordAdmin"), - permission::record_admin_permissions(), - ts::ctx(&mut scenario), - ); + trail + .roles_mut() + .create_role( + &admin_cap, + string::utf8(b"RecordAdmin"), + permission::record_admin_permissions(), + &clock, + ts::ctx(&mut scenario), + ); - let record_cap = trail.new_capability( - &admin_cap, - &string::utf8(b"RecordAdmin"), - ts::ctx(&mut scenario), - ); + let record_cap = trail + .roles_mut() + .new_capability_without_restrictions( + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); // Add 5 records - let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); clock.set_for_testing(initial_time_for_testing() + 1000); let mut i = 0u64; @@ -660,10 +704,8 @@ fun test_combined_time_and_count_locking_both_lock() { i = i + 1; }; - clock::destroy_for_testing(clock); transfer::public_transfer(record_cap, admin); - admin_cap.destroy_for_testing(); - ts::return_shared(trail); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); }; // Test: Records locked by BOTH time and count @@ -711,24 +753,28 @@ fun test_combined_locking_time_expired_but_count_locked() { // Create RecordAdmin role and add records ts::next_tx(&mut scenario, admin); { - let admin_cap = ts::take_from_sender(&scenario); - let mut trail = ts::take_shared>(&scenario); + let (admin_cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); - trail.create_role( - &admin_cap, - string::utf8(b"RecordAdmin"), - permission::record_admin_permissions(), - ts::ctx(&mut scenario), - ); + trail + .roles_mut() + .create_role( + &admin_cap, + string::utf8(b"RecordAdmin"), + permission::record_admin_permissions(), + &clock, + ts::ctx(&mut scenario), + ); - let record_cap = trail.new_capability( - &admin_cap, - &string::utf8(b"RecordAdmin"), - ts::ctx(&mut scenario), - ); + let record_cap = trail + .roles_mut() + .new_capability_without_restrictions( + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); // Add 5 records - let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); clock.set_for_testing(initial_time_for_testing() + 1000); let mut i = 0u64; @@ -743,10 +789,8 @@ fun test_combined_locking_time_expired_but_count_locked() { i = i + 1; }; - clock::destroy_for_testing(clock); transfer::public_transfer(record_cap, admin); - admin_cap.destroy_for_testing(); - ts::return_shared(trail); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); }; // Test: Time lock expired, but count lock still active for last 2 records @@ -795,24 +839,28 @@ fun test_combined_locking_count_satisfied_but_time_locked() { // Create RecordAdmin role and add records ts::next_tx(&mut scenario, admin); { - let admin_cap = ts::take_from_sender(&scenario); - let mut trail = ts::take_shared>(&scenario); + let (admin_cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); - trail.create_role( - &admin_cap, - string::utf8(b"RecordAdmin"), - permission::record_admin_permissions(), - ts::ctx(&mut scenario), - ); + trail + .roles_mut() + .create_role( + &admin_cap, + string::utf8(b"RecordAdmin"), + permission::record_admin_permissions(), + &clock, + ts::ctx(&mut scenario), + ); - let record_cap = trail.new_capability( - &admin_cap, - &string::utf8(b"RecordAdmin"), - ts::ctx(&mut scenario), - ); + let record_cap = trail + .roles_mut() + .new_capability_without_restrictions( + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); // Add 5 records - let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); clock.set_for_testing(initial_time_for_testing() + 1000); let mut i = 0u64; @@ -827,10 +875,8 @@ fun test_combined_locking_count_satisfied_but_time_locked() { i = i + 1; }; - clock::destroy_for_testing(clock); transfer::public_transfer(record_cap, admin); - admin_cap.destroy_for_testing(); - ts::return_shared(trail); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); }; // Test: Count lock satisfied (not in last 2), but time lock still active @@ -876,24 +922,28 @@ fun test_combined_locking_both_satisfied_can_delete() { // Create RecordAdmin role and add records ts::next_tx(&mut scenario, admin); { - let admin_cap = ts::take_from_sender(&scenario); - let mut trail = ts::take_shared>(&scenario); + let (admin_cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); - trail.create_role( - &admin_cap, - string::utf8(b"RecordAdmin"), - permission::record_admin_permissions(), - ts::ctx(&mut scenario), - ); + trail + .roles_mut() + .create_role( + &admin_cap, + string::utf8(b"RecordAdmin"), + permission::record_admin_permissions(), + &clock, + ts::ctx(&mut scenario), + ); - let record_cap = trail.new_capability( - &admin_cap, - &string::utf8(b"RecordAdmin"), - ts::ctx(&mut scenario), - ); + let record_cap = trail + .roles_mut() + .new_capability_without_restrictions( + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); // Add 5 records - let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); clock.set_for_testing(initial_time_for_testing() + 1000); let mut i = 0u64; @@ -908,10 +958,9 @@ fun test_combined_locking_both_satisfied_can_delete() { i = i + 1; }; - clock::destroy_for_testing(clock); transfer::public_transfer(record_cap, admin); admin_cap.destroy_for_testing(); - ts::return_shared(trail); + cleanup_trail_and_clock(trail, clock); }; // Test: Both locks satisfied - can delete diff --git a/audit-trail-move/tests/metadata_tests.move b/audit-trail-move/tests/metadata_tests.move index db2e8a59..eac3aea2 100644 --- a/audit-trail-move/tests/metadata_tests.move +++ b/audit-trail-move/tests/metadata_tests.move @@ -4,9 +4,12 @@ module audit_trail::metadata_tests; use audit_trail::{ capability::Capability, locking, - main::{Self, AuditTrail}, permission, - test_utils::{TestData, setup_test_audit_trail} + test_utils::{ + setup_test_audit_trail, + fetch_capability_trail_and_clock, + cleanup_capability_trail_and_clock + } }; use iota::test_scenario as ts; use std::string; @@ -34,41 +37,45 @@ fun test_update_metadata_success() { // Create MetadataAdmin role and capability ts::next_tx(&mut scenario, admin_user); { - let admin_cap = ts::take_from_sender(&scenario); - let mut trail = ts::take_shared>(&scenario); + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); // Create MetadataAdmin role with metadata permissions let metadata_perms = permission::metadata_admin_permissions(); - trail.create_role( - &admin_cap, - string::utf8(b"MetadataAdmin"), - metadata_perms, - ts::ctx(&mut scenario), - ); + trail + .roles_mut() + .create_role( + &admin_cap, + string::utf8(b"MetadataAdmin"), + metadata_perms, + &clock, + ts::ctx(&mut scenario), + ); // Issue capability to metadata admin user - let metadata_cap = trail.new_capability( - &admin_cap, - &string::utf8(b"MetadataAdmin"), - ts::ctx(&mut scenario), - ); + let metadata_cap = trail + .roles_mut() + .new_capability_without_restrictions( + &admin_cap, + &string::utf8(b"MetadataAdmin"), + &clock, + ts::ctx(&mut scenario), + ); transfer::public_transfer(metadata_cap, metadata_admin_user); - ts::return_to_sender(&scenario, admin_cap); - ts::return_shared(trail); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); }; // Test: MetadataAdmin updates metadata ts::next_tx(&mut scenario, metadata_admin_user); { - let metadata_cap = ts::take_from_sender(&scenario); - let mut trail = ts::take_shared>(&scenario); + let (metadata_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); // Update metadata let new_metadata = std::option::some(string::utf8(b"Updated metadata value")); trail.update_metadata( &metadata_cap, new_metadata, + &clock, ts::ctx(&mut scenario), ); @@ -77,21 +84,20 @@ fun test_update_metadata_success() { assert!(current_metadata.is_some(), 0); assert!(*current_metadata.borrow() == string::utf8(b"Updated metadata value"), 1); - ts::return_to_sender(&scenario, metadata_cap); - ts::return_shared(trail); + cleanup_capability_trail_and_clock(&scenario, metadata_cap, trail, clock); }; // Test: Update metadata again to verify multiple updates work ts::next_tx(&mut scenario, metadata_admin_user); { - let metadata_cap = ts::take_from_sender(&scenario); - let mut trail = ts::take_shared>(&scenario); + let (metadata_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); // Update to different value let new_metadata = std::option::some(string::utf8(b"Second update")); trail.update_metadata( &metadata_cap, new_metadata, + &clock, ts::ctx(&mut scenario), ); @@ -100,20 +106,19 @@ fun test_update_metadata_success() { assert!(current_metadata.is_some(), 2); assert!(*current_metadata.borrow() == string::utf8(b"Second update"), 3); - ts::return_to_sender(&scenario, metadata_cap); - ts::return_shared(trail); + cleanup_capability_trail_and_clock(&scenario, metadata_cap, trail, clock); }; // Test: Set metadata to none ts::next_tx(&mut scenario, metadata_admin_user); { - let metadata_cap = ts::take_from_sender(&scenario); - let mut trail = ts::take_shared>(&scenario); + let (metadata_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); // Set to none trail.update_metadata( &metadata_cap, std::option::none(), + &clock, ts::ctx(&mut scenario), ); @@ -121,8 +126,7 @@ fun test_update_metadata_success() { let current_metadata = trail.metadata(); assert!(current_metadata.is_none(), 4); - ts::return_to_sender(&scenario, metadata_cap); - ts::return_shared(trail); + cleanup_capability_trail_and_clock(&scenario, metadata_cap, trail, clock); }; ts::end(scenario); @@ -131,7 +135,7 @@ fun test_update_metadata_success() { // ===== Error Case Tests ===== #[test] -#[expected_failure(abort_code = main::EPermissionDenied)] +#[expected_failure(abort_code = audit_trail::role_map::ECapabilityPermissionDenied)] fun test_update_metadata_permission_denied() { let admin_user = @0xAD; let user = @0xB0B; @@ -152,51 +156,54 @@ fun test_update_metadata_permission_denied() { // Create role WITHOUT update_metadata permission ts::next_tx(&mut scenario, admin_user); { - let admin_cap = ts::take_from_sender(&scenario); - let mut trail = ts::take_shared>(&scenario); + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); // Create role with only add_record permission (no update_metadata) let perms = permission::from_vec(vector[permission::add_record()]); - trail.create_role( - &admin_cap, - string::utf8(b"NoMetadataPerm"), - perms, - ts::ctx(&mut scenario), - ); - - let user_cap = trail.new_capability( - &admin_cap, - &string::utf8(b"NoMetadataPerm"), - ts::ctx(&mut scenario), - ); + trail + .roles_mut() + .create_role( + &admin_cap, + string::utf8(b"NoMetadataPerm"), + perms, + &clock, + ts::ctx(&mut scenario), + ); + + let user_cap = trail + .roles_mut() + .new_capability_without_restrictions( + &admin_cap, + &string::utf8(b"NoMetadataPerm"), + &clock, + ts::ctx(&mut scenario), + ); transfer::public_transfer(user_cap, user); - ts::return_to_sender(&scenario, admin_cap); - ts::return_shared(trail); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); }; // User tries to update metadata - should fail ts::next_tx(&mut scenario, user); { - let user_cap = ts::take_from_sender(&scenario); - let mut trail = ts::take_shared>(&scenario); + let (user_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); // This should fail - no update_metadata permission trail.update_metadata( &user_cap, std::option::some(string::utf8(b"Should fail")), + &clock, ts::ctx(&mut scenario), ); - ts::return_to_sender(&scenario, user_cap); - ts::return_shared(trail); + cleanup_capability_trail_and_clock(&scenario, user_cap, trail, clock); }; ts::end(scenario); } #[test] -#[expected_failure(abort_code = main::ECapabilityHasBeenRevoked)] +#[expected_failure(abort_code = audit_trail::role_map::ECapabilityHasBeenRevoked)] fun test_update_metadata_revoked_capability() { let admin_user = @0xAD; let metadata_admin_user = @0xB0B; @@ -217,59 +224,62 @@ fun test_update_metadata_revoked_capability() { // Create MetadataAdmin role and capability ts::next_tx(&mut scenario, admin_user); { - let admin_cap = ts::take_from_sender(&scenario); - let mut trail = ts::take_shared>(&scenario); + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); // Create MetadataAdmin role let metadata_perms = permission::metadata_admin_permissions(); - trail.create_role( - &admin_cap, - string::utf8(b"MetadataAdmin"), - metadata_perms, - ts::ctx(&mut scenario), - ); + trail + .roles_mut() + .create_role( + &admin_cap, + string::utf8(b"MetadataAdmin"), + metadata_perms, + &clock, + ts::ctx(&mut scenario), + ); // Issue capability - let metadata_cap = trail.new_capability( - &admin_cap, - &string::utf8(b"MetadataAdmin"), - ts::ctx(&mut scenario), - ); + let metadata_cap = trail + .roles_mut() + .new_capability_without_restrictions( + &admin_cap, + &string::utf8(b"MetadataAdmin"), + &clock, + ts::ctx(&mut scenario), + ); transfer::public_transfer(metadata_cap, metadata_admin_user); - ts::return_to_sender(&scenario, admin_cap); - ts::return_shared(trail); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); }; // Revoke the capability ts::next_tx(&mut scenario, admin_user); { - let admin_cap = ts::take_from_sender(&scenario); - let mut trail = ts::take_shared>(&scenario); + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); let metadata_cap = ts::take_from_address(&scenario, metadata_admin_user); - trail.revoke_capability(&admin_cap, metadata_cap.id()); + trail + .roles_mut() + .revoke_capability(&admin_cap, metadata_cap.id(), &clock, ts::ctx(&mut scenario)); ts::return_to_address(metadata_admin_user, metadata_cap); - ts::return_to_sender(&scenario, admin_cap); - ts::return_shared(trail); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); }; // Try to use revoked capability - should fail ts::next_tx(&mut scenario, metadata_admin_user); { - let metadata_cap = ts::take_from_sender(&scenario); - let mut trail = ts::take_shared>(&scenario); + let (metadata_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); // This should fail - capability has been revoked trail.update_metadata( &metadata_cap, std::option::some(string::utf8(b"Should fail")), + &clock, ts::ctx(&mut scenario), ); - ts::return_to_sender(&scenario, metadata_cap); - ts::return_shared(trail); + cleanup_capability_trail_and_clock(&scenario, metadata_cap, trail, clock); }; ts::end(scenario); diff --git a/audit-trail-move/tests/record_tests.move b/audit-trail-move/tests/record_tests.move index 02e692ad..c52dec30 100644 --- a/audit-trail-move/tests/record_tests.move +++ b/audit-trail-move/tests/record_tests.move @@ -2,7 +2,6 @@ module audit_trail::record_tests; use audit_trail::{ - capability::Capability, locking, main::{Self, AuditTrail}, permission, @@ -12,7 +11,10 @@ use audit_trail::{ new_test_data, initial_time_for_testing, test_data_value, - test_data_message + test_data_message, + fetch_capability_trail_and_clock, + cleanup_capability_trail_and_clock, + cleanup_trail_and_clock } }; use iota::{clock, test_scenario as ts}; @@ -39,34 +41,36 @@ fun test_add_record_to_empty_trail() { // Create RecordAdmin role ts::next_tx(&mut scenario, admin); { - let admin_cap = ts::take_from_sender(&scenario); - let mut trail = ts::take_shared>(&scenario); + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); - trail.create_role( - &admin_cap, - string::utf8(b"RecordAdmin"), - permission::record_admin_permissions(), - ts::ctx(&mut scenario), - ); + trail + .roles_mut() + .create_role( + &admin_cap, + string::utf8(b"RecordAdmin"), + permission::record_admin_permissions(), + &clock, + ts::ctx(&mut scenario), + ); - let record_cap = trail.new_capability( - &admin_cap, - &string::utf8(b"RecordAdmin"), - ts::ctx(&mut scenario), - ); + let record_cap = trail + .roles_mut() + .new_capability_without_restrictions( + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); transfer::public_transfer(record_cap, admin); admin_cap.destroy_for_testing(); - ts::return_shared(trail); + cleanup_trail_and_clock(trail, clock); }; // Add record ts::next_tx(&mut scenario, admin); { - let mut trail = ts::take_shared>(&scenario); - let record_cap = ts::take_from_sender(&scenario); - - let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + let (record_cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); clock.set_for_testing(initial_time_for_testing() + 1000); // Verify initial state @@ -87,9 +91,7 @@ fun test_add_record_to_empty_trail() { assert!(!trail.is_empty(), 3); assert!(trail.has_record(0), 4); - clock::destroy_for_testing(clock); - ts::return_to_sender(&scenario, record_cap); - ts::return_shared(trail); + cleanup_capability_trail_and_clock(&scenario, record_cap, trail, clock); }; ts::end(scenario); @@ -114,34 +116,36 @@ fun test_add_multiple_records() { // Create RecordAdmin role ts::next_tx(&mut scenario, admin); { - let admin_cap = ts::take_from_sender(&scenario); - let mut trail = ts::take_shared>(&scenario); + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); - trail.create_role( - &admin_cap, - string::utf8(b"RecordAdmin"), - permission::record_admin_permissions(), - ts::ctx(&mut scenario), - ); + trail + .roles_mut() + .create_role( + &admin_cap, + string::utf8(b"RecordAdmin"), + permission::record_admin_permissions(), + &clock, + ts::ctx(&mut scenario), + ); - let record_cap = trail.new_capability( - &admin_cap, - &string::utf8(b"RecordAdmin"), - ts::ctx(&mut scenario), - ); + let record_cap = trail + .roles_mut() + .new_capability_without_restrictions( + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); transfer::public_transfer(record_cap, admin); admin_cap.destroy_for_testing(); - ts::return_shared(trail); + cleanup_trail_and_clock(trail, clock); }; // Add multiple records ts::next_tx(&mut scenario, admin); { - let mut trail = ts::take_shared>(&scenario); - let record_cap = ts::take_from_sender(&scenario); - - let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + let (record_cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); clock.set_for_testing(initial_time_for_testing() + 1000); // Add 3 records @@ -164,16 +168,14 @@ fun test_add_multiple_records() { assert!(trail.has_record(2), 3); assert!(!trail.has_record(3), 4); - clock::destroy_for_testing(clock); - ts::return_to_sender(&scenario, record_cap); - ts::return_shared(trail); + cleanup_capability_trail_and_clock(&scenario, record_cap, trail, clock); }; ts::end(scenario); } #[test] -#[expected_failure(abort_code = main::EPermissionDenied)] +#[expected_failure(abort_code = audit_trail::role_map::ECapabilityPermissionDenied)] fun test_add_record_permission_denied() { let admin = @0xAD; let mut scenario = ts::begin(admin); @@ -192,30 +194,37 @@ fun test_add_record_permission_denied() { // Create role WITHOUT AddRecord permission ts::next_tx(&mut scenario, admin); { - let admin_cap = ts::take_from_sender(&scenario); - let mut trail = ts::take_shared>(&scenario); + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); let perms = permission::from_vec(vector[permission::delete_record()]); - trail.create_role(&admin_cap, string::utf8(b"NoAddPerm"), perms, ts::ctx(&mut scenario)); + trail + .roles_mut() + .create_role( + &admin_cap, + string::utf8(b"NoAddPerm"), + perms, + &clock, + ts::ctx(&mut scenario), + ); - let no_add_cap = trail.new_capability( - &admin_cap, - &string::utf8(b"NoAddPerm"), - ts::ctx(&mut scenario), - ); + let no_add_cap = trail + .roles_mut() + .new_capability_without_restrictions( + &admin_cap, + &string::utf8(b"NoAddPerm"), + &clock, + ts::ctx(&mut scenario), + ); transfer::public_transfer(no_add_cap, admin); admin_cap.destroy_for_testing(); - ts::return_shared(trail); + cleanup_trail_and_clock(trail, clock); }; // Try to add record - should fail ts::next_tx(&mut scenario, admin); { - let mut trail = ts::take_shared>(&scenario); - let no_add_cap = ts::take_from_sender(&scenario); - - let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + let (no_add_cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); clock.set_for_testing(initial_time_for_testing() + 1000); // This should fail - no AddRecord permission @@ -227,9 +236,7 @@ fun test_add_record_permission_denied() { ts::ctx(&mut scenario), ); - clock::destroy_for_testing(clock); - ts::return_to_sender(&scenario, no_add_cap); - ts::return_shared(trail); + cleanup_capability_trail_and_clock(&scenario, no_add_cap, trail, clock); }; ts::end(scenario); @@ -256,34 +263,36 @@ fun test_delete_record_success() { // Create RecordAdmin role ts::next_tx(&mut scenario, admin); { - let admin_cap = ts::take_from_sender(&scenario); - let mut trail = ts::take_shared>(&scenario); + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); - trail.create_role( - &admin_cap, - string::utf8(b"RecordAdmin"), - permission::record_admin_permissions(), - ts::ctx(&mut scenario), - ); + trail + .roles_mut() + .create_role( + &admin_cap, + string::utf8(b"RecordAdmin"), + permission::record_admin_permissions(), + &clock, + ts::ctx(&mut scenario), + ); - let record_cap = trail.new_capability( - &admin_cap, - &string::utf8(b"RecordAdmin"), - ts::ctx(&mut scenario), - ); + let record_cap = trail + .roles_mut() + .new_capability_without_restrictions( + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); transfer::public_transfer(record_cap, admin); admin_cap.destroy_for_testing(); - ts::return_shared(trail); + cleanup_trail_and_clock(trail, clock); }; // Delete record ts::next_tx(&mut scenario, admin); { - let mut trail = ts::take_shared>(&scenario); - let record_cap = ts::take_from_sender(&scenario); - - let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + let (record_cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); clock.set_for_testing(initial_time_for_testing() + 1000); // Verify initial state @@ -297,16 +306,14 @@ fun test_delete_record_success() { assert!(trail.record_count() == 1, 2); // record_count doesn't decrease assert!(!trail.has_record(0), 3); // but record is gone - clock::destroy_for_testing(clock); - ts::return_to_sender(&scenario, record_cap); - ts::return_shared(trail); + cleanup_capability_trail_and_clock(&scenario, record_cap, trail, clock); }; ts::end(scenario); } #[test] -#[expected_failure(abort_code = main::EPermissionDenied)] +#[expected_failure(abort_code = audit_trail::role_map::ECapabilityPermissionDenied)] fun test_delete_record_permission_denied() { let admin = @0xAD; let mut scenario = ts::begin(admin); @@ -325,38 +332,43 @@ fun test_delete_record_permission_denied() { // Create role WITHOUT DeleteRecord permission ts::next_tx(&mut scenario, admin); { - let admin_cap = ts::take_from_sender(&scenario); - let mut trail = ts::take_shared>(&scenario); + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); let perms = permission::from_vec(vector[permission::add_record()]); - trail.create_role(&admin_cap, string::utf8(b"NoDeletePerm"), perms, ts::ctx(&mut scenario)); + trail + .roles_mut() + .create_role( + &admin_cap, + string::utf8(b"NoDeletePerm"), + perms, + &clock, + ts::ctx(&mut scenario), + ); - let no_delete_cap = trail.new_capability( - &admin_cap, - &string::utf8(b"NoDeletePerm"), - ts::ctx(&mut scenario), - ); + let no_delete_cap = trail + .roles_mut() + .new_capability_without_restrictions( + &admin_cap, + &string::utf8(b"NoDeletePerm"), + &clock, + ts::ctx(&mut scenario), + ); transfer::public_transfer(no_delete_cap, admin); admin_cap.destroy_for_testing(); - ts::return_shared(trail); + cleanup_trail_and_clock(trail, clock); }; // Try to delete record - should fail ts::next_tx(&mut scenario, admin); { - let mut trail = ts::take_shared>(&scenario); - let no_delete_cap = ts::take_from_sender(&scenario); - - let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + let (no_delete_cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); clock.set_for_testing(initial_time_for_testing() + 1000); // This should fail - no DeleteRecord permission trail.delete_record(&no_delete_cap, 0, &clock, ts::ctx(&mut scenario)); - clock::destroy_for_testing(clock); - ts::return_to_sender(&scenario, no_delete_cap); - ts::return_shared(trail); + cleanup_capability_trail_and_clock(&scenario, no_delete_cap, trail, clock); }; ts::end(scenario); @@ -382,42 +394,42 @@ fun test_delete_record_not_found() { // Create RecordAdmin role ts::next_tx(&mut scenario, admin); { - let admin_cap = ts::take_from_sender(&scenario); - let mut trail = ts::take_shared>(&scenario); + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); - trail.create_role( - &admin_cap, - string::utf8(b"RecordAdmin"), - permission::record_admin_permissions(), - ts::ctx(&mut scenario), - ); + trail + .roles_mut() + .create_role( + &admin_cap, + string::utf8(b"RecordAdmin"), + permission::record_admin_permissions(), + &clock, + ts::ctx(&mut scenario), + ); - let record_cap = trail.new_capability( - &admin_cap, - &string::utf8(b"RecordAdmin"), - ts::ctx(&mut scenario), - ); + let record_cap = trail + .roles_mut() + .new_capability_without_restrictions( + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); transfer::public_transfer(record_cap, admin); admin_cap.destroy_for_testing(); - ts::return_shared(trail); + cleanup_trail_and_clock(trail, clock); }; // Try to delete non-existent record - should fail ts::next_tx(&mut scenario, admin); { - let mut trail = ts::take_shared>(&scenario); - let record_cap = ts::take_from_sender(&scenario); - - let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + let (record_cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); clock.set_for_testing(initial_time_for_testing() + 1000); // This should fail - record doesn't exist trail.delete_record(&record_cap, 999, &clock, ts::ctx(&mut scenario)); - clock::destroy_for_testing(clock); - ts::return_to_sender(&scenario, record_cap); - ts::return_shared(trail); + cleanup_capability_trail_and_clock(&scenario, record_cap, trail, clock); }; ts::end(scenario); @@ -443,43 +455,43 @@ fun test_delete_record_time_locked() { // Create RecordAdmin role ts::next_tx(&mut scenario, admin); { - let admin_cap = ts::take_from_sender(&scenario); - let mut trail = ts::take_shared>(&scenario); + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); - trail.create_role( - &admin_cap, - string::utf8(b"RecordAdmin"), - permission::record_admin_permissions(), - ts::ctx(&mut scenario), - ); + trail + .roles_mut() + .create_role( + &admin_cap, + string::utf8(b"RecordAdmin"), + permission::record_admin_permissions(), + &clock, + ts::ctx(&mut scenario), + ); - let record_cap = trail.new_capability( - &admin_cap, - &string::utf8(b"RecordAdmin"), - ts::ctx(&mut scenario), - ); + let record_cap = trail + .roles_mut() + .new_capability_without_restrictions( + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); transfer::public_transfer(record_cap, admin); admin_cap.destroy_for_testing(); - ts::return_shared(trail); + cleanup_trail_and_clock(trail, clock); }; // Try to delete locked record - should fail ts::next_tx(&mut scenario, admin); { - let mut trail = ts::take_shared>(&scenario); - let record_cap = ts::take_from_sender(&scenario); - + let (record_cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); // Time is only 1 second after creation - still within lock window - let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); clock.set_for_testing(initial_time_for_testing() + 1000); // +1 second // This should fail - record is time-locked trail.delete_record(&record_cap, 0, &clock, ts::ctx(&mut scenario)); - clock::destroy_for_testing(clock); - ts::return_to_sender(&scenario, record_cap); - ts::return_shared(trail); + cleanup_capability_trail_and_clock(&scenario, record_cap, trail, clock); }; ts::end(scenario); @@ -505,42 +517,42 @@ fun test_delete_record_count_locked() { // Create RecordAdmin role ts::next_tx(&mut scenario, admin); { - let admin_cap = ts::take_from_sender(&scenario); - let mut trail = ts::take_shared>(&scenario); + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); - trail.create_role( - &admin_cap, - string::utf8(b"RecordAdmin"), - permission::record_admin_permissions(), - ts::ctx(&mut scenario), - ); + trail + .roles_mut() + .create_role( + &admin_cap, + string::utf8(b"RecordAdmin"), + permission::record_admin_permissions(), + &clock, + ts::ctx(&mut scenario), + ); - let record_cap = trail.new_capability( - &admin_cap, - &string::utf8(b"RecordAdmin"), - ts::ctx(&mut scenario), - ); + let record_cap = trail + .roles_mut() + .new_capability_without_restrictions( + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); transfer::public_transfer(record_cap, admin); admin_cap.destroy_for_testing(); - ts::return_shared(trail); + cleanup_trail_and_clock(trail, clock); }; // Try to delete locked record - should fail ts::next_tx(&mut scenario, admin); { - let mut trail = ts::take_shared>(&scenario); - let record_cap = ts::take_from_sender(&scenario); - - let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + let (record_cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); clock.set_for_testing(initial_time_for_testing() + 1000); // Only 1 record exists, and last 5 are locked, so it's locked trail.delete_record(&record_cap, 0, &clock, ts::ctx(&mut scenario)); - clock::destroy_for_testing(clock); - ts::return_to_sender(&scenario, record_cap); - ts::return_shared(trail); + cleanup_capability_trail_and_clock(&scenario, record_cap, trail, clock); }; ts::end(scenario); @@ -630,27 +642,31 @@ fun test_first_last_sequence() { // Create RecordAdmin and test sequence functions ts::next_tx(&mut scenario, admin); { - let admin_cap = ts::take_from_sender(&scenario); - let mut trail = ts::take_shared>(&scenario); + let (admin_cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); // Empty trail assert!(trail.first_sequence().is_none(), 0); assert!(trail.last_sequence().is_none(), 1); - trail.create_role( - &admin_cap, - string::utf8(b"RecordAdmin"), - permission::record_admin_permissions(), - ts::ctx(&mut scenario), - ); + trail + .roles_mut() + .create_role( + &admin_cap, + string::utf8(b"RecordAdmin"), + permission::record_admin_permissions(), + &clock, + ts::ctx(&mut scenario), + ); - let record_cap = trail.new_capability( - &admin_cap, - &string::utf8(b"RecordAdmin"), - ts::ctx(&mut scenario), - ); + let record_cap = trail + .roles_mut() + .new_capability_without_restrictions( + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); - let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); clock.set_for_testing(initial_time_for_testing() + 1000); // Add first record @@ -689,10 +705,8 @@ fun test_first_last_sequence() { assert!(trail.first_sequence() == std::option::some(0), 6); assert!(trail.last_sequence() == std::option::some(2), 7); - clock::destroy_for_testing(clock); - admin_cap.destroy_for_testing(); record_cap.destroy_for_testing(); - ts::return_shared(trail); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); }; ts::end(scenario); diff --git a/audit-trail-move/tests/role_tests.move b/audit-trail-move/tests/role_tests.move index 856222b4..b7e2de8f 100644 --- a/audit-trail-move/tests/role_tests.move +++ b/audit-trail-move/tests/role_tests.move @@ -3,10 +3,11 @@ module audit_trail::role_tests; use audit_trail::{ locking, - main::initial_admin_role_name, + main::{initial_admin_role_name, AuditTrail}, permission, test_utils::{ Self, + TestData, setup_test_audit_trail, fetch_capability_trail_and_clock, cleanup_capability_trail_and_clock @@ -227,29 +228,42 @@ fun test_delete_role_success() { ts::next_tx(&mut scenario, admin_user); { - let admin_cap = ts::take_from_sender(&scenario); - let mut trail = ts::take_shared>(&scenario); + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); // Verify initial state - only Admin role exists assert!(trail.roles().size() == 1, 0); // Create a role to delete let perms = permission::from_vec(vector[permission::add_record()]); - trail.create_role(&admin_cap, string::utf8(b"RoleToDelete"), perms, ts::ctx(&mut scenario)); + trail + .roles_mut() + .create_role( + &admin_cap, + string::utf8(b"RoleToDelete"), + perms, + &clock, + ts::ctx(&mut scenario), + ); // Verify the role was created assert!(trail.roles().size() == 2, 1); - assert!(trail.has_role(&string::utf8(b"RoleToDelete")), 2); + assert!(trail.roles().has_role(&string::utf8(b"RoleToDelete")), 2); // Delete the role - trail.delete_role(&admin_cap, &string::utf8(b"RoleToDelete"), ts::ctx(&mut scenario)); + trail + .roles_mut() + .delete_role( + &admin_cap, + &string::utf8(b"RoleToDelete"), + &clock, + ts::ctx(&mut scenario), + ); // Verify the role was deleted assert!(trail.roles().size() == 1, 3); - assert!(!trail.has_role(&string::utf8(b"RoleToDelete")), 4); + assert!(!trail.roles().has_role(&string::utf8(b"RoleToDelete")), 4); - ts::return_to_sender(&scenario, admin_cap); - ts::return_shared(trail); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); }; ts::end(scenario); @@ -258,7 +272,7 @@ fun test_delete_role_success() { // ===== Error Case Tests ===== #[test] -#[expected_failure(abort_code = main::EPermissionDenied)] +#[expected_failure(abort_code = audit_trail::role_map::ECapabilityPermissionDenied)] fun test_create_role_permission_denied() { let admin_user = @0xAD; let user = @0xB0B; @@ -279,44 +293,59 @@ fun test_create_role_permission_denied() { // Create role without RolesAdd permission ts::next_tx(&mut scenario, admin_user); { - let admin_cap = ts::take_from_sender(&scenario); - let mut trail = ts::take_shared>(&scenario); + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); // Create role WITHOUT add_roles permission let perms = permission::from_vec(vector[permission::add_record()]); - trail.create_role(&admin_cap, string::utf8(b"NoRolesPerm"), perms, ts::ctx(&mut scenario)); + trail + .roles_mut() + .create_role( + &admin_cap, + string::utf8(b"NoRolesPerm"), + perms, + &clock, + ts::ctx(&mut scenario), + ); - let user_cap = trail.new_capability( - &admin_cap, - &string::utf8(b"NoRolesPerm"), - ts::ctx(&mut scenario), - ); + let user_cap = trail + .roles_mut() + .new_capability_without_restrictions( + &admin_cap, + &string::utf8(b"NoRolesPerm"), + &clock, + ts::ctx(&mut scenario), + ); transfer::public_transfer(user_cap, user); - ts::return_to_sender(&scenario, admin_cap); - ts::return_shared(trail); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); }; // User tries to create a role - should fail ts::next_tx(&mut scenario, user); { - let user_cap = ts::take_from_sender(&scenario); - let mut trail = ts::take_shared>(&scenario); + let (user_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); let perms = permission::from_vec(vector[permission::add_record()]); // This should fail - no add_roles permission - trail.create_role(&user_cap, string::utf8(b"NewRole"), perms, ts::ctx(&mut scenario)); + trail + .roles_mut() + .create_role( + &user_cap, + string::utf8(b"NewRole"), + perms, + &clock, + ts::ctx(&mut scenario), + ); - ts::return_to_sender(&scenario, user_cap); - ts::return_shared(trail); + cleanup_capability_trail_and_clock(&scenario, user_cap, trail, clock); }; ts::end(scenario); } #[test] -#[expected_failure(abort_code = main::EPermissionDenied)] +#[expected_failure(abort_code = audit_trail::role_map::ECapabilityPermissionDenied)] fun test_delete_role_permission_denied() { let admin_user = @0xAD; let user = @0xB0B; @@ -337,51 +366,63 @@ fun test_delete_role_permission_denied() { // Create roles ts::next_tx(&mut scenario, admin_user); { - let admin_cap = ts::take_from_sender(&scenario); - let mut trail = ts::take_shared>(&scenario); + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); // Create a role to delete let perms = permission::from_vec(vector[permission::add_record()]); - trail.create_role(&admin_cap, string::utf8(b"RoleToDelete"), perms, ts::ctx(&mut scenario)); + trail + .roles_mut() + .create_role( + &admin_cap, + string::utf8(b"RoleToDelete"), + perms, + &clock, + ts::ctx(&mut scenario), + ); // Create role WITHOUT delete_roles permission let no_delete_perms = permission::from_vec(vector[permission::add_record()]); - trail.create_role( - &admin_cap, - string::utf8(b"NoDeleteRolePerm"), - no_delete_perms, - ts::ctx(&mut scenario), - ); + trail + .roles_mut() + .create_role( + &admin_cap, + string::utf8(b"NoDeleteRolePerm"), + no_delete_perms, + &clock, + ts::ctx(&mut scenario), + ); - let user_cap = trail.new_capability( - &admin_cap, - &string::utf8(b"NoDeleteRolePerm"), - ts::ctx(&mut scenario), - ); + let user_cap = trail + .roles_mut() + .new_capability_without_restrictions( + &admin_cap, + &string::utf8(b"NoDeleteRolePerm"), + &clock, + ts::ctx(&mut scenario), + ); transfer::public_transfer(user_cap, user); - ts::return_to_sender(&scenario, admin_cap); - ts::return_shared(trail); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); }; // User tries to delete a role - should fail ts::next_tx(&mut scenario, user); { - let user_cap = ts::take_from_sender(&scenario); - let mut trail = ts::take_shared>(&scenario); + let (user_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); // This should fail - no delete_roles permission - trail.delete_role(&user_cap, &string::utf8(b"RoleToDelete"), ts::ctx(&mut scenario)); + trail + .roles_mut() + .delete_role(&user_cap, &string::utf8(b"RoleToDelete"), &clock, ts::ctx(&mut scenario)); - ts::return_to_sender(&scenario, user_cap); - ts::return_shared(trail); + cleanup_capability_trail_and_clock(&scenario, user_cap, trail, clock); }; ts::end(scenario); } #[test] -#[expected_failure(abort_code = main::EPermissionDenied)] +#[expected_failure(abort_code = audit_trail::role_map::ECapabilityPermissionDenied)] fun test_update_role_permissions_permission_denied() { let admin_user = @0xAD; let user = @0xB0B; @@ -402,58 +443,71 @@ fun test_update_role_permissions_permission_denied() { // Create roles ts::next_tx(&mut scenario, admin_user); { - let admin_cap = ts::take_from_sender(&scenario); - let mut trail = ts::take_shared>(&scenario); + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); // Create a role to update let perms = permission::from_vec(vector[permission::add_record()]); - trail.create_role(&admin_cap, string::utf8(b"RoleToUpdate"), perms, ts::ctx(&mut scenario)); + trail + .roles_mut() + .create_role( + &admin_cap, + string::utf8(b"RoleToUpdate"), + perms, + &clock, + ts::ctx(&mut scenario), + ); // Create role WITHOUT update_roles permission let no_update_perms = permission::from_vec(vector[permission::add_record()]); - trail.create_role( - &admin_cap, - string::utf8(b"NoUpdateRolePerm"), - no_update_perms, - ts::ctx(&mut scenario), - ); + trail + .roles_mut() + .create_role( + &admin_cap, + string::utf8(b"NoUpdateRolePerm"), + no_update_perms, + &clock, + ts::ctx(&mut scenario), + ); - let user_cap = trail.new_capability( - &admin_cap, - &string::utf8(b"NoUpdateRolePerm"), - ts::ctx(&mut scenario), - ); + let user_cap = trail + .roles_mut() + .new_capability_without_restrictions( + &admin_cap, + &string::utf8(b"NoUpdateRolePerm"), + &clock, + ts::ctx(&mut scenario), + ); transfer::public_transfer(user_cap, user); - ts::return_to_sender(&scenario, admin_cap); - ts::return_shared(trail); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); }; // User tries to update a role - should fail ts::next_tx(&mut scenario, user); { - let user_cap = ts::take_from_sender(&scenario); - let mut trail = ts::take_shared>(&scenario); + let (user_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); let new_perms = permission::from_vec(vector[permission::delete_record()]); // This should fail - no update_roles permission - trail.update_role_permissions( - &user_cap, - &string::utf8(b"RoleToUpdate"), - new_perms, - ts::ctx(&mut scenario), - ); + trail + .roles_mut() + .update_role_permissions( + &user_cap, + &string::utf8(b"RoleToUpdate"), + new_perms, + &clock, + ts::ctx(&mut scenario), + ); - ts::return_to_sender(&scenario, user_cap); - ts::return_shared(trail); + cleanup_capability_trail_and_clock(&scenario, user_cap, trail, clock); }; ts::end(scenario); } #[test] -#[expected_failure(abort_code = main::ERoleDoesNotExist)] +#[expected_failure(abort_code = audit_trail::role_map::ERoleDoesNotExist)] fun test_get_role_permissions_nonexistent() { let admin_user = @0xAD; @@ -474,7 +528,7 @@ fun test_get_role_permissions_nonexistent() { let trail = ts::take_shared>(&scenario); // This should fail - role doesn't exist - let _perms = trail.get_role_permissions(&string::utf8(b"NonExistentRole")); + let _perms = trail.roles().get_role_permissions(&string::utf8(b"NonExistentRole")); ts::return_shared(trail); }; @@ -500,46 +554,50 @@ fun test_update_role_permissions_success() { ts::next_tx(&mut scenario, admin_user); { - let admin_cap = ts::take_from_sender(&scenario); - let mut trail = ts::take_shared>(&scenario); + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); // Create a role with add_record permission let initial_perms = permission::from_vec(vector[permission::add_record()]); - trail.create_role( - &admin_cap, - string::utf8(b"TestRole"), - initial_perms, - ts::ctx(&mut scenario), - ); + trail + .roles_mut() + .create_role( + &admin_cap, + string::utf8(b"TestRole"), + initial_perms, + &clock, + ts::ctx(&mut scenario), + ); // Verify the role was created with add_record permission - let perms = trail.get_role_permissions(&string::utf8(b"TestRole")); + let perms = trail.roles().get_role_permissions(&string::utf8(b"TestRole")); assert!(perms.contains(&permission::add_record()), 0); assert!(!perms.contains(&permission::delete_record()), 1); // Update the role to have delete_record permission instead let new_perms = permission::from_vec(vector[permission::delete_record()]); - trail.update_role_permissions( - &admin_cap, - &string::utf8(b"TestRole"), - new_perms, - ts::ctx(&mut scenario), - ); + trail + .roles_mut() + .update_role_permissions( + &admin_cap, + &string::utf8(b"TestRole"), + new_perms, + &clock, + ts::ctx(&mut scenario), + ); // Verify the permissions were updated - let updated_perms = trail.get_role_permissions(&string::utf8(b"TestRole")); + let updated_perms = trail.roles().get_role_permissions(&string::utf8(b"TestRole")); assert!(!updated_perms.contains(&permission::add_record()), 2); assert!(updated_perms.contains(&permission::delete_record()), 3); - ts::return_to_sender(&scenario, admin_cap); - ts::return_shared(trail); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); }; ts::end(scenario); } #[test] -#[expected_failure(abort_code = main::ERoleDoesNotExist)] +#[expected_failure(abort_code = audit_trail::role_map::ERoleDoesNotExist)] fun test_update_role_permissions_nonexistent() { let admin_user = @0xAD; @@ -557,21 +615,22 @@ fun test_update_role_permissions_nonexistent() { ts::next_tx(&mut scenario, admin_user); { - let admin_cap = ts::take_from_sender(&scenario); - let mut trail = ts::take_shared>(&scenario); + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); let new_perms = permission::from_vec(vector[permission::add_record()]); // This should fail - role doesn't exist - trail.update_role_permissions( - &admin_cap, - &string::utf8(b"NonExistentRole"), - new_perms, - ts::ctx(&mut scenario), - ); + trail + .roles_mut() + .update_role_permissions( + &admin_cap, + &string::utf8(b"NonExistentRole"), + new_perms, + &clock, + ts::ctx(&mut scenario), + ); - ts::return_to_sender(&scenario, admin_cap); - ts::return_shared(trail); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); }; ts::end(scenario); diff --git a/audit-trail-move/tests/test_utils.move b/audit-trail-move/tests/test_utils.move index 34927881..e0f5739e 100644 --- a/audit-trail-move/tests/test_utils.move +++ b/audit-trail-move/tests/test_utils.move @@ -83,3 +83,8 @@ public(package) fun cleanup_capability_trail_and_clock( ts::return_to_sender(scenario, cap); ts::return_shared(trail); } + +public(package) fun cleanup_trail_and_clock(trail: AuditTrail, clock: Clock) { + iota::clock::destroy_for_testing(clock); + ts::return_shared(trail); +} From cce0159eb90039096b4a80fde722f0816fb35105 Mon Sep 17 00:00:00 2001 From: chrisgitiota Date: Fri, 16 Jan 2026 11:34:58 +0100 Subject: [PATCH 036/189] Removed superfluous "public use fun" statements and function prefixes --- audit-trail-move/sources/capability.move | 41 ++++++++---------------- 1 file changed, 13 insertions(+), 28 deletions(-) diff --git a/audit-trail-move/sources/capability.move b/audit-trail-move/sources/capability.move index 70ac5794..d793a064 100644 --- a/audit-trail-move/sources/capability.move +++ b/audit-trail-move/sources/capability.move @@ -127,48 +127,48 @@ public(package) fun new_capability_for_address( } /// Get the capability's ID -public fun cap_id(cap: &Capability): ID { +public fun id(cap: &Capability): ID { object::uid_to_inner(&cap.id) } /// Get the capability's role -public fun cap_role(cap: &Capability): &String { +public fun role(cap: &Capability): &String { &cap.role } /// Get the capability's security_vault_id -public fun cap_security_vault_id(cap: &Capability): ID { +public fun security_vault_id(cap: &Capability): ID { cap.security_vault_id } /// Check if the capability has a specific role -public fun cap_has_role(cap: &Capability, role: &String): bool { +public fun has_role(cap: &Capability, role: &String): bool { &cap.role == role } // Get the capability's issued_to address -public fun cap_issued_to(cap: &Capability): &Option

{ +public fun issued_to(cap: &Capability): &Option
{ &cap.issued_to } // Get the capability's valid_from timestamp -public fun cap_valid_from(cap: &Capability): &Option { +public fun valid_from(cap: &Capability): &Option { &cap.valid_from } // Get the capability's valid_until timestamp -public fun cap_valid_until(cap: &Capability): &Option { +public fun valid_until(cap: &Capability): &Option { &cap.valid_until } // Check if the capability is currently valid for `clock::timestamp_ms(clock)` -public fun cap_is_currently_valid(cap: &Capability, clock: &Clock): bool { +public fun is_currently_valid(cap: &Capability, clock: &Clock): bool { let current_ts = clock::timestamp_ms(clock) / 1000; // convert to seconds cap.is_valid_for_timestamp(current_ts) } // Check if the capability is valid for a specific timestamp (in seconds since Unix epoch) -public fun cap_is_valid_for_timestamp(cap: &Capability, timestamp_secs: u64): bool { +public fun is_valid_for_timestamp(cap: &Capability, timestamp_secs: u64): bool { let valid_from_ok = if (cap.valid_from.is_some()) { let from = cap.valid_from.borrow(); timestamp_secs >= *from @@ -185,7 +185,7 @@ public fun cap_is_valid_for_timestamp(cap: &Capability, timestamp_secs: u64): bo } /// Destroy a capability -public(package) fun cap_destroy(cap: Capability) { +public(package) fun destroy(cap: Capability) { let Capability { id, role: _role, @@ -198,21 +198,6 @@ public(package) fun cap_destroy(cap: Capability) { } #[test_only] -public fun cap_destroy_for_testing(cap: Capability) { - cap_destroy(cap); -} - -// ===== public use statements ===== - -public use fun cap_id as Capability.id; -public use fun cap_role as Capability.role; -public use fun cap_security_vault_id as Capability.security_vault_id; -public use fun cap_has_role as Capability.has_role; -public use fun cap_destroy as Capability.destroy; -public use fun cap_issued_to as Capability.issued_to; -public use fun cap_valid_from as Capability.valid_from; -public use fun cap_valid_until as Capability.valid_until; -public use fun cap_is_currently_valid as Capability.is_currently_valid; -public use fun cap_is_valid_for_timestamp as Capability.is_valid_for_timestamp; -#[test_only] -public use fun cap_destroy_for_testing as Capability.destroy_for_testing; +public fun destroy_for_testing(cap: Capability) { + destroy(cap); +} From 0e851cf000eab8ddcc300a3782070ab534bc495d Mon Sep 17 00:00:00 2001 From: Yasir Date: Tue, 20 Jan 2026 12:47:22 +0300 Subject: [PATCH 037/189] chore: Disable the `abort_without_constant` lint for all test modules. --- audit-trail-move/tests/capability_tests.move | 1 + audit-trail-move/tests/create_audit_trail_tests.move | 1 + audit-trail-move/tests/locking_tests.move | 1 + audit-trail-move/tests/metadata_tests.move | 1 + audit-trail-move/tests/permission_tests.move | 1 + audit-trail-move/tests/record_tests.move | 1 + audit-trail-move/tests/role_tests.move | 1 + 7 files changed, 7 insertions(+) diff --git a/audit-trail-move/tests/capability_tests.move b/audit-trail-move/tests/capability_tests.move index 9631539e..d4bc27cc 100644 --- a/audit-trail-move/tests/capability_tests.move +++ b/audit-trail-move/tests/capability_tests.move @@ -1,3 +1,4 @@ +#[allow(lint(abort_without_constant))] #[test_only] module audit_trail::capability_tests; diff --git a/audit-trail-move/tests/create_audit_trail_tests.move b/audit-trail-move/tests/create_audit_trail_tests.move index 7e361533..7c07ce5d 100644 --- a/audit-trail-move/tests/create_audit_trail_tests.move +++ b/audit-trail-move/tests/create_audit_trail_tests.move @@ -1,3 +1,4 @@ +#[allow(lint(abort_without_constant))] #[test_only] module audit_trail::create_audit_trail_tests; diff --git a/audit-trail-move/tests/locking_tests.move b/audit-trail-move/tests/locking_tests.move index 12fcc336..7a81e714 100644 --- a/audit-trail-move/tests/locking_tests.move +++ b/audit-trail-move/tests/locking_tests.move @@ -1,3 +1,4 @@ +#[allow(lint(abort_without_constant))] #[test_only] module audit_trail::locking_tests; diff --git a/audit-trail-move/tests/metadata_tests.move b/audit-trail-move/tests/metadata_tests.move index eac3aea2..1b619d47 100644 --- a/audit-trail-move/tests/metadata_tests.move +++ b/audit-trail-move/tests/metadata_tests.move @@ -1,3 +1,4 @@ +#[allow(lint(abort_without_constant))] #[test_only] module audit_trail::metadata_tests; diff --git a/audit-trail-move/tests/permission_tests.move b/audit-trail-move/tests/permission_tests.move index 3b0e5bd6..847467ce 100644 --- a/audit-trail-move/tests/permission_tests.move +++ b/audit-trail-move/tests/permission_tests.move @@ -1,3 +1,4 @@ +#[allow(lint(abort_without_constant))] #[test_only] module audit_trail::permission_tests; diff --git a/audit-trail-move/tests/record_tests.move b/audit-trail-move/tests/record_tests.move index 02015ff5..c89c2ef6 100644 --- a/audit-trail-move/tests/record_tests.move +++ b/audit-trail-move/tests/record_tests.move @@ -1,3 +1,4 @@ +#[allow(lint(abort_without_constant))] #[test_only] module audit_trail::record_tests; diff --git a/audit-trail-move/tests/role_tests.move b/audit-trail-move/tests/role_tests.move index b7e2de8f..e4c32634 100644 --- a/audit-trail-move/tests/role_tests.move +++ b/audit-trail-move/tests/role_tests.move @@ -1,3 +1,4 @@ +#[allow(lint(abort_without_constant))] #[test_only] module audit_trail::role_tests; From bc904b6313dc008e2975a0bd283338a924401173 Mon Sep 17 00:00:00 2001 From: Yasir Date: Tue, 27 Jan 2026 19:37:53 +0300 Subject: [PATCH 038/189] feat: Add package versioning and migration support to audit trail --- audit-trail-move/sources/audit_trail.move | 41 +++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/audit-trail-move/sources/audit_trail.move b/audit-trail-move/sources/audit_trail.move index c9d25d94..c9f90260 100644 --- a/audit-trail-move/sources/audit_trail.move +++ b/audit-trail-move/sources/audit_trail.move @@ -25,10 +25,16 @@ const EPermissionDenied: vector = b"The role associated with the provided capability does not have the required permission"; #[error] const ERecordLocked: vector = b"The record is locked and cannot be deleted"; +#[error] +const EPackageVersionMismatch: vector = + b"The package version of the trail does not match the expected version"; // ===== Constants ===== const INITIAL_ADMIN_ROLE_NAME: vector = b"Admin"; +// Package version, incremented when the package is updated +const PACKAGE_VERSION: u64 = 1; + // ===== Core Structures ===== /// Metadata set at trail creation @@ -61,6 +67,8 @@ public struct AuditTrail has key, store { immutable_metadata: Option, /// Can be updated by holders of MetadataUpdate permission updatable_metadata: Option, + /// Package version + package_version: u64, } // ===== Events ===== @@ -192,6 +200,7 @@ public fun create( roles, immutable_metadata: trail_metadata, updatable_metadata, + package_version: PACKAGE_VERSION, }; transfer::share_object(trail); @@ -210,6 +219,28 @@ public fun initial_admin_role_name(): String { INITIAL_ADMIN_ROLE_NAME.to_string() } +/// Migrate the trail to the latest package version +entry fun migrate( + trail: &mut AuditTrail, + cap: &Capability, + clock: &Clock, + ctx: &TxContext, +) { + assert!(trail.package_version < PACKAGE_VERSION, EPackageVersionMismatch); + assert!( + trail + .roles + .is_capability_valid( + cap, + &permission::delete_audit_trail(), + clock, + ctx, + ), + EPermissionDenied, + ); + trail.package_version = PACKAGE_VERSION; +} + // ===== Record Operations ===== /// Add a record to the trail @@ -223,6 +254,7 @@ public fun add_record( clock: &Clock, ctx: &mut TxContext, ) { + assert!(trail.package_version == PACKAGE_VERSION, EPackageVersionMismatch); assert!( trail .roles @@ -270,6 +302,7 @@ public fun delete_record( clock: &Clock, ctx: &mut TxContext, ) { + assert!(trail.package_version == PACKAGE_VERSION, EPackageVersionMismatch); assert!( trail .roles @@ -330,6 +363,7 @@ public fun update_locking_config( clock: &Clock, ctx: &mut TxContext, ) { + assert!(trail.package_version == PACKAGE_VERSION, EPackageVersionMismatch); assert!( trail .roles @@ -352,6 +386,7 @@ public fun update_locking_config_for_delete_record( clock: &Clock, ctx: &mut TxContext, ) { + assert!(trail.package_version == PACKAGE_VERSION, EPackageVersionMismatch); assert!( trail .roles @@ -374,6 +409,7 @@ public fun update_metadata( clock: &Clock, ctx: &mut TxContext, ) { + assert!(trail.package_version == PACKAGE_VERSION, EPackageVersionMismatch); assert!( trail .roles @@ -458,27 +494,32 @@ public fun last_sequence(trail: &AuditTrail): Option { /// Get a record by sequence number public fun get_record(trail: &AuditTrail, sequence_number: u64): &Record { + assert!(trail.package_version == PACKAGE_VERSION, EPackageVersionMismatch); assert!(linked_table::contains(&trail.records, sequence_number), ERecordNotFound); linked_table::borrow(&trail.records, sequence_number) } /// Check if a record exists at the given sequence number public fun has_record(trail: &AuditTrail, sequence_number: u64): bool { + assert!(trail.package_version == PACKAGE_VERSION, EPackageVersionMismatch); linked_table::contains(&trail.records, sequence_number) } /// Returns all records of the audit trail public fun records(trail: &AuditTrail): &LinkedTable> { + assert!(trail.package_version == PACKAGE_VERSION, EPackageVersionMismatch); &trail.records } // ===== Role and Capability Functions ===== /// Returns a reference the RoleMap managing the roles and capabilities used in the audit trail public fun roles(trail: &AuditTrail): &RoleMap { + assert!(trail.package_version == PACKAGE_VERSION, EPackageVersionMismatch); &trail.roles } /// Returns a mutable reference to the RoleMap managing the roles and capabilities used in the audit trail public fun roles_mut(trail: &mut AuditTrail): &mut RoleMap { + assert!(trail.package_version == PACKAGE_VERSION, EPackageVersionMismatch); &mut trail.roles } From 6ff5403f0e5c9b8d23bd0a40443503c3690fe8ae Mon Sep 17 00:00:00 2001 From: Yasir Date: Wed, 28 Jan 2026 06:51:39 +0300 Subject: [PATCH 039/189] refactor: Rename package_version to version in AuditTrail struct and update related assertions --- audit-trail-move/sources/audit_trail.move | 28 +++++++++++------------ 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/audit-trail-move/sources/audit_trail.move b/audit-trail-move/sources/audit_trail.move index c9f90260..31a18f1f 100644 --- a/audit-trail-move/sources/audit_trail.move +++ b/audit-trail-move/sources/audit_trail.move @@ -68,7 +68,7 @@ public struct AuditTrail has key, store { /// Can be updated by holders of MetadataUpdate permission updatable_metadata: Option, /// Package version - package_version: u64, + version: u64, } // ===== Events ===== @@ -200,7 +200,7 @@ public fun create( roles, immutable_metadata: trail_metadata, updatable_metadata, - package_version: PACKAGE_VERSION, + version: PACKAGE_VERSION, }; transfer::share_object(trail); @@ -226,7 +226,7 @@ entry fun migrate( clock: &Clock, ctx: &TxContext, ) { - assert!(trail.package_version < PACKAGE_VERSION, EPackageVersionMismatch); + assert!(trail.version < PACKAGE_VERSION, EPackageVersionMismatch); assert!( trail .roles @@ -238,7 +238,7 @@ entry fun migrate( ), EPermissionDenied, ); - trail.package_version = PACKAGE_VERSION; + trail.version = PACKAGE_VERSION; } // ===== Record Operations ===== @@ -254,7 +254,7 @@ public fun add_record( clock: &Clock, ctx: &mut TxContext, ) { - assert!(trail.package_version == PACKAGE_VERSION, EPackageVersionMismatch); + assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); assert!( trail .roles @@ -302,7 +302,7 @@ public fun delete_record( clock: &Clock, ctx: &mut TxContext, ) { - assert!(trail.package_version == PACKAGE_VERSION, EPackageVersionMismatch); + assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); assert!( trail .roles @@ -363,7 +363,7 @@ public fun update_locking_config( clock: &Clock, ctx: &mut TxContext, ) { - assert!(trail.package_version == PACKAGE_VERSION, EPackageVersionMismatch); + assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); assert!( trail .roles @@ -386,7 +386,7 @@ public fun update_locking_config_for_delete_record( clock: &Clock, ctx: &mut TxContext, ) { - assert!(trail.package_version == PACKAGE_VERSION, EPackageVersionMismatch); + assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); assert!( trail .roles @@ -409,7 +409,7 @@ public fun update_metadata( clock: &Clock, ctx: &mut TxContext, ) { - assert!(trail.package_version == PACKAGE_VERSION, EPackageVersionMismatch); + assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); assert!( trail .roles @@ -494,32 +494,32 @@ public fun last_sequence(trail: &AuditTrail): Option { /// Get a record by sequence number public fun get_record(trail: &AuditTrail, sequence_number: u64): &Record { - assert!(trail.package_version == PACKAGE_VERSION, EPackageVersionMismatch); + assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); assert!(linked_table::contains(&trail.records, sequence_number), ERecordNotFound); linked_table::borrow(&trail.records, sequence_number) } /// Check if a record exists at the given sequence number public fun has_record(trail: &AuditTrail, sequence_number: u64): bool { - assert!(trail.package_version == PACKAGE_VERSION, EPackageVersionMismatch); + assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); linked_table::contains(&trail.records, sequence_number) } /// Returns all records of the audit trail public fun records(trail: &AuditTrail): &LinkedTable> { - assert!(trail.package_version == PACKAGE_VERSION, EPackageVersionMismatch); + assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); &trail.records } // ===== Role and Capability Functions ===== /// Returns a reference the RoleMap managing the roles and capabilities used in the audit trail public fun roles(trail: &AuditTrail): &RoleMap { - assert!(trail.package_version == PACKAGE_VERSION, EPackageVersionMismatch); + assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); &trail.roles } /// Returns a mutable reference to the RoleMap managing the roles and capabilities used in the audit trail public fun roles_mut(trail: &mut AuditTrail): &mut RoleMap { - assert!(trail.package_version == PACKAGE_VERSION, EPackageVersionMismatch); + assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); &mut trail.roles } From c6f17099c63226e6c8b1e3ef124c0291b37f7943 Mon Sep 17 00:00:00 2001 From: Yasir Date: Fri, 30 Jan 2026 12:36:50 +0300 Subject: [PATCH 040/189] feat: Introduce record correction tracking in audit trail - Updated compiler version to 1.15.0. - Added a new module `record_correction` for managing correction relationships between audit records. - Integrated `RecordCorrection` into the `Record` struct to track corrections. - Updated relevant functions to utilize the new correction tracking feature. --- audit-trail-move/Move.lock | 2 +- audit-trail-move/sources/audit_trail.move | 3 + audit-trail-move/sources/record.move | 12 ++++ .../sources/record_correction.move | 64 +++++++++++++++++++ 4 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 audit-trail-move/sources/record_correction.move diff --git a/audit-trail-move/Move.lock b/audit-trail-move/Move.lock index ca7ee131..b59fb99a 100644 --- a/audit-trail-move/Move.lock +++ b/audit-trail-move/Move.lock @@ -42,6 +42,6 @@ dependencies = [ ] [move.toolchain-version] -compiler-version = "1.14.1-rc" +compiler-version = "1.15.0" edition = "2024.beta" flavor = "iota" diff --git a/audit-trail-move/sources/audit_trail.move b/audit-trail-move/sources/audit_trail.move index 31a18f1f..74cda053 100644 --- a/audit-trail-move/sources/audit_trail.move +++ b/audit-trail-move/sources/audit_trail.move @@ -12,6 +12,7 @@ use audit_trail::{ locking::{Self, LockingConfig, LockingWindow, set_delete_record_lock}, permission::{Self, Permission}, record::{Self, Record}, + record_correction, role_map::{Self, RoleMap} }; use iota::{clock::{Self, Clock}, event, linked_table::{Self, LinkedTable}}; @@ -155,6 +156,7 @@ public fun create( 0, creator, timestamp, + record_correction::new(), ); linked_table::push_back(&mut records, 0, record); @@ -278,6 +280,7 @@ public fun add_record( seq, caller, timestamp, + record_correction::new(), ); linked_table::push_back(&mut trail.records, seq, record); diff --git a/audit-trail-move/sources/record.move b/audit-trail-move/sources/record.move index c384f101..de968b81 100644 --- a/audit-trail-move/sources/record.move +++ b/audit-trail-move/sources/record.move @@ -7,6 +7,8 @@ /// and addressed by trail_id + sequence_number. module audit_trail::record; +use audit_trail::record_correction::RecordCorrection; +use iota::vec_set::{Self, VecSet}; use std::string::String; /// A single record in the audit trail (stored in LinkedTable, no ObjectID) @@ -21,6 +23,8 @@ public struct Record has store { added_by: address, /// When this record was added (milliseconds) added_at: u64, + /// Correction tracker for this record + correction: RecordCorrection, } // ===== Constructors ===== @@ -32,6 +36,7 @@ public(package) fun new( sequence_number: u64, added_by: address, added_at: u64, + correction: RecordCorrection, ): Record { Record { stored_data, @@ -39,6 +44,7 @@ public(package) fun new( sequence_number, added_by, added_at, + correction, } } @@ -69,6 +75,11 @@ public fun added_at(record: &Record): u64 { record.added_at } +/// Get the correction tracker for this record +public fun correction(record: &Record): &RecordCorrection { + &record.correction +} + // ===== Destructors ===== /// Destroy a record (package-private, called by audit_trail module when deleting) @@ -80,5 +91,6 @@ public(package) fun destroy(record: Record) { sequence_number: _, added_by: _, added_at: _, + correction: _, } = record; } diff --git a/audit-trail-move/sources/record_correction.move b/audit-trail-move/sources/record_correction.move new file mode 100644 index 00000000..4b2d9015 --- /dev/null +++ b/audit-trail-move/sources/record_correction.move @@ -0,0 +1,64 @@ +// Copyright (c) 2025 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +/// Module for tracking correction relationships for a record +module audit_trail::record_correction; + +use iota::vec_set::{Self, VecSet}; + +/// Bidirectional correction tracking for audit records +public struct RecordCorrection has copy, drop, store { + replaces: VecSet, + is_replaced_by: Option, +} + +/// Create a new correction tracker for a normal (non-correcting) record +public fun new(): RecordCorrection { + RecordCorrection { + replaces: vec_set::empty(), + is_replaced_by: option::none(), + } +} + +/// Create a correction tracker for a correcting record +public fun with_replaces(replaced_seq_nums: VecSet): RecordCorrection { + RecordCorrection { + replaces: replaced_seq_nums, + is_replaced_by: option::none(), + } +} + +/// Get the set of sequence numbers this record replaces +public fun replaces(correction: &RecordCorrection): &VecSet { + &correction.replaces +} + +/// Get the sequence number of the record that replaced this one +public fun is_replaced_by(correction: &RecordCorrection): Option { + correction.is_replaced_by +} + +/// Check if this record is a correction (replaces other records) +public fun is_correction(correction: &RecordCorrection): bool { + !vec_set::is_empty(&correction.replaces) +} + +/// Check if this record has been replaced by another record +public fun is_replaced(correction: &RecordCorrection): bool { + correction.is_replaced_by.is_some() +} + +/// Set the sequence number of the record that replaced this one +public(package) fun set_replaced_by(correction: &mut RecordCorrection, replacement_seq: u64) { + correction.is_replaced_by = option::some(replacement_seq); +} + +/// Add a sequence number to the set of records this record replaces +public(package) fun add_replaces(correction: &mut RecordCorrection, seq_num: u64) { + correction.replaces.insert(seq_num); +} + +/// Destroy a RecordCorrection +public(package) fun destroy(correction: RecordCorrection) { + let RecordCorrection { replaces: _, is_replaced_by: _ } = correction; +} From b6f355de8da67f2e537bd3b13f31b177f641a947 Mon Sep 17 00:00:00 2001 From: Yasir Date: Mon, 2 Feb 2026 12:51:23 +0300 Subject: [PATCH 041/189] refactor: Simplify documentation comments in Record module --- audit-trail-move/sources/record.move | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/audit-trail-move/sources/record.move b/audit-trail-move/sources/record.move index de968b81..37649df5 100644 --- a/audit-trail-move/sources/record.move +++ b/audit-trail-move/sources/record.move @@ -3,15 +3,14 @@ /// Record module for audit trail entries /// -/// A Record represents a single entry in an audit trail, stored in a LinkedTable -/// and addressed by trail_id + sequence_number. +/// A Record represents a single entry in an audit trail, stored in a +/// LinkedTable and addressed by trail_id + sequence_number. module audit_trail::record; use audit_trail::record_correction::RecordCorrection; -use iota::vec_set::{Self, VecSet}; use std::string::String; -/// A single record in the audit trail (stored in LinkedTable, no ObjectID) +/// A single record in the audit trail public struct Record has store { /// Arbitrary data stored on-chain stored_data: D, @@ -29,7 +28,7 @@ public struct Record has store { // ===== Constructors ===== -/// Create a new record (package-private, called by audit_trails module) +/// Create a new record public(package) fun new( stored_data: D, record_metadata: Option, @@ -80,10 +79,7 @@ public fun correction(record: &Record): &RecordCorrection { &record.correction } -// ===== Destructors ===== - -/// Destroy a record (package-private, called by audit_trail module when deleting) -/// Note: D must have `drop` ability to allow deletion +/// Destroy a record public(package) fun destroy(record: Record) { let Record { stored_data: _, From cc62a4c8f983ce2a7ddb9cb98847e6d57d6f2105 Mon Sep 17 00:00:00 2001 From: Yasir Date: Tue, 3 Feb 2026 13:12:32 +0300 Subject: [PATCH 042/189] feat: Scaffold audit trail module with client and package management --- Cargo.toml | 2 +- audit-trail-move/Move.history.json | 4 + audit-trail-rs/Cargo.toml | 59 ++++++++ audit-trail-rs/README.md | 2 +- audit-trail-rs/build.rs | 29 ++++ audit-trail-rs/src/client/full_client.rs | 36 +++++ audit-trail-rs/src/client/mod.rs | 30 ++++ audit-trail-rs/src/client/read_only.rs | 128 ++++++++++++++++++ audit-trail-rs/src/core/mod.rs | 6 + audit-trail-rs/src/error.rs | 45 ++++++ .../src/iota_interaction_adapter.rs | 11 ++ audit-trail-rs/src/lib.rs | 14 ++ audit-trail-rs/src/package.rs | 59 ++++++++ 13 files changed, 423 insertions(+), 2 deletions(-) create mode 100644 audit-trail-move/Move.history.json create mode 100644 audit-trail-rs/Cargo.toml create mode 100644 audit-trail-rs/build.rs create mode 100644 audit-trail-rs/src/client/full_client.rs create mode 100644 audit-trail-rs/src/client/mod.rs create mode 100644 audit-trail-rs/src/client/read_only.rs create mode 100644 audit-trail-rs/src/core/mod.rs create mode 100644 audit-trail-rs/src/error.rs create mode 100644 audit-trail-rs/src/iota_interaction_adapter.rs create mode 100644 audit-trail-rs/src/lib.rs create mode 100644 audit-trail-rs/src/package.rs diff --git a/Cargo.toml b/Cargo.toml index 5ad5699f..4bb36de9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ rust-version = "1.85" [workspace] resolver = "2" -members = ["examples", "notarization-rs"] +members = ["examples", "notarization-rs", "audit-trail-rs"] exclude = ["bindings/wasm/notarization_wasm"] [workspace.dependencies] diff --git a/audit-trail-move/Move.history.json b/audit-trail-move/Move.history.json new file mode 100644 index 00000000..d5ba8b0c --- /dev/null +++ b/audit-trail-move/Move.history.json @@ -0,0 +1,4 @@ +{ + "aliases": {}, + "envs": {} +} \ No newline at end of file diff --git a/audit-trail-rs/Cargo.toml b/audit-trail-rs/Cargo.toml new file mode 100644 index 00000000..88d6ebb1 --- /dev/null +++ b/audit-trail-rs/Cargo.toml @@ -0,0 +1,59 @@ +[package] +name = "audit-trails" +version = "0.1.0-alpha" +authors.workspace = true +edition.workspace = true +homepage.workspace = true +keywords = ["iota", "tangle", "utxo", "audit-trail", "audit-trails"] +license.workspace = true +readme = "./README.md" +repository.workspace = true +rust-version.workspace = true +description = "An audit trail toolkit for the IOTA Ledger." + +[dependencies] +anyhow.workspace = true +async-trait.workspace = true +bcs.workspace = true +iota-caip = { git = "https://github.com/iotaledger/iota-caip.git", default-features = false, features = ["iota"], optional = true } +iota_interaction = { workspace = true, default-features = false } +product_common = { workspace = true, default-features = false, features = ["transaction"] } +secret-storage = { workspace = true, default-features = false } +serde.workspace = true +serde_json.workspace = true +strum.workspace = true +thiserror.workspace = true + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +iota_interaction_rust = { workspace = true, default-features = false } +hyper = { workspace = true } # Fix for iota-sdk 1.13 issue with axum-server. +iota-sdk = { workspace = true } +tokio = { workspace = true } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +iota_interaction_ts.workspace = true +tokio = { version = "1.46.1", default-features = false, features = ["sync"] } + +[dev-dependencies] +async-trait.workspace = true +iota_interaction = { workspace = true } +product_common = { workspace = true, features = ["transaction", "test-utils"] } + +[build-dependencies] +product_common = { workspace = true, features = ["move-history-manager"] } + +[features] +default = ["send-sync"] +send-sync = [ + "send-sync-storage", + "product_common/send-sync", + "iota_interaction/send-sync-transaction", +] +# Enables `Send` + `Sync` bounds for the storage traits. +send-sync-storage = ["secret-storage/send-sync-storage"] +# Enables an high-level integration with IOTA gas-station. +gas-station = ["product_common/gas-station"] +# Uses a default HTTP Client instead of a user-provided one. +default-http-client = ["product_common/default-http-client"] +# Enables the interaction with IOTA Resource Locators. +irl = ["dep:iota-caip"] diff --git a/audit-trail-rs/README.md b/audit-trail-rs/README.md index 71a444e3..fe437cd3 100644 --- a/audit-trail-rs/README.md +++ b/audit-trail-rs/README.md @@ -1 +1 @@ -# IOTA Audit Trails +# IOTA Audit Trails (WIP) diff --git a/audit-trail-rs/build.rs b/audit-trail-rs/build.rs new file mode 100644 index 00000000..3486b438 --- /dev/null +++ b/audit-trail-rs/build.rs @@ -0,0 +1,29 @@ +// Copyright 2020-2025 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use std::path::PathBuf; + +use product_common::move_history_manager::MoveHistoryManager; + +fn main() { + let move_lock_path = "../audit-trail-move/Move.lock"; + println!("[build.rs] move_lock_path: {move_lock_path}"); + let move_history_path = "../audit-trail-move/Move.history.json"; + println!("[build.rs] move_history_path: {move_history_path}"); + + MoveHistoryManager::new( + &PathBuf::from(move_lock_path), + &PathBuf::from(move_history_path), + // We will watch the default watch list (`get_default_aliases_to_watch()`) in this build script + // so we leave the `additional_aliases_to_watch` argument vec empty. + // Use for example `vec!["localnet".to_string()]` instead, if you don't want to ignore `localnet`. + vec![], + ) + .manage_history_file(|message| { + println!("[build.rs] {}", message); + }) + .expect("Successfully managed Move history file"); + + // Tell Cargo to rerun this build script if the Move.lock file changes. + println!("cargo::rerun-if-changed={move_lock_path}"); +} diff --git a/audit-trail-rs/src/client/full_client.rs b/audit-trail-rs/src/client/full_client.rs new file mode 100644 index 00000000..b1353c3a --- /dev/null +++ b/audit-trail-rs/src/client/full_client.rs @@ -0,0 +1,36 @@ +// Copyright 2020-2025 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +//! A minimal full client wrapper for audit trail interactions. +//! +//! This is a scaffold that will be extended with transaction-building capabilities +//! once the Move contract API is finalized. + +use std::ops::Deref; + +use crate::client::read_only::AuditTrailClientReadOnly; + +/// A full client that wraps the read-only client and will host write operations. +#[derive(Clone)] +pub struct AuditTrailClient { + read_client: AuditTrailClientReadOnly, +} + +impl Deref for AuditTrailClient { + type Target = AuditTrailClientReadOnly; + fn deref(&self) -> &Self::Target { + &self.read_client + } +} + +impl AuditTrailClient { + /// Creates a new full client from an existing read-only client. + pub fn new(read_client: AuditTrailClientReadOnly) -> Self { + Self { read_client } + } + + /// Returns a reference to the underlying read-only client. + pub const fn read_only(&self) -> &AuditTrailClientReadOnly { + &self.read_client + } +} diff --git a/audit-trail-rs/src/client/mod.rs b/audit-trail-rs/src/client/mod.rs new file mode 100644 index 00000000..c87ae7f9 --- /dev/null +++ b/audit-trail-rs/src/client/mod.rs @@ -0,0 +1,30 @@ +// Copyright 2020-2025 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +//! Client implementations for interacting with audit trails on the IOTA blockchain. +//! +//! This module provides two client types: +//! - [`read_only`]: Read-only access to audit trail data +//! - [`full_client`]: Full read-write access with transaction capabilities + +use iota_interaction::IotaClientTrait; +use product_common::network_name::NetworkName; + +use crate::error::Error; +use crate::iota_interaction_adapter::IotaClientAdapter; + +pub mod full_client; +pub mod read_only; + +pub use full_client::*; +pub use read_only::*; + +/// Returns the network-id also known as chain-identifier provided by the specified iota_client +async fn network_id(iota_client: &IotaClientAdapter) -> Result { + let network_id = iota_client + .read_api() + .get_chain_identifier() + .await + .map_err(|e| Error::RpcError(e.to_string()))?; + Ok(network_id.try_into().expect("chain ID is a valid network name")) +} diff --git a/audit-trail-rs/src/client/read_only.rs b/audit-trail-rs/src/client/read_only.rs new file mode 100644 index 00000000..1f590fd3 --- /dev/null +++ b/audit-trail-rs/src/client/read_only.rs @@ -0,0 +1,128 @@ +// Copyright 2020-2025 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +//! A read-only client for interacting with IOTA Audit Trail module objects. +//! +//! This client provides minimal setup to resolve the audit trail package ID +//! and basic access to the underlying IOTA client adapter. + +use std::ops::Deref; + +#[cfg(not(target_arch = "wasm32"))] +use iota_interaction::IotaClient; +use iota_interaction::types::base_types::ObjectID; +#[cfg(target_arch = "wasm32")] +use iota_interaction_ts::bindings::WasmIotaClient; +use product_common::network_name::NetworkName; +use product_common::package_registry::Env; + +use super::network_id; +use crate::error::Error; +use crate::iota_interaction_adapter::IotaClientAdapter; +use crate::package; + +/// A read-only client for interacting with audit trail module objects on a specific network. +#[derive(Clone)] +pub struct AuditTrailClientReadOnly { + /// The underlying IOTA client adapter used for communication. + iota_client: IotaClientAdapter, + /// The [`ObjectID`] of the deployed audit trail package (smart contract). + audit_trail_pkg_id: ObjectID, + /// The name of the network this client is connected to (e.g., "mainnet", "testnet"). + network: NetworkName, + /// Raw chain identifier returned by the IOTA node. + chain_id: String, +} + +impl Deref for AuditTrailClientReadOnly { + type Target = IotaClientAdapter; + fn deref(&self) -> &Self::Target { + &self.iota_client + } +} + +impl AuditTrailClientReadOnly { + /// Returns the name of the network the client is connected to. + pub const fn network(&self) -> &NetworkName { + &self.network + } + + /// Returns the raw chain identifier for the network this client is connected to. + pub fn chain_id(&self) -> &str { + &self.chain_id + } + + /// Returns the package ID used by this client. + pub fn package_id(&self) -> ObjectID { + self.audit_trail_pkg_id + } + + /// Returns a reference to the underlying IOTA client adapter. + pub const fn iota_client(&self) -> &IotaClientAdapter { + &self.iota_client + } + + /// Attempts to create a new [`AuditTrailClientReadOnly`] from a given IOTA client. + /// + /// This resolves the package ID from the internal registry based on the network. + pub async fn new( + #[cfg(target_arch = "wasm32")] iota_client: WasmIotaClient, + #[cfg(not(target_arch = "wasm32"))] iota_client: IotaClient, + ) -> Result { + let client = IotaClientAdapter::new(iota_client); + let network = network_id(&client).await?; + Self::new_internal(client, network).await + } + + async fn new_internal(iota_client: IotaClientAdapter, network: NetworkName) -> Result { + let chain_id = network.as_ref().to_string(); + let (network, audit_trail_pkg_id) = { + let package_registry = package::audit_trail_package_registry().await; + let package_id = package_registry + .package_id(&network) + .ok_or_else(|| { + Error::InvalidConfig(format!( + "no information for a published `audit_trail` package on network {network}; try to use `AuditTrailClientReadOnly::new_with_pkg_id`" + )) + })?; + let network = match chain_id.as_str() { + product_common::package_registry::MAINNET_CHAIN_ID => { + NetworkName::try_from("iota").expect("valid network name") + } + _ => package_registry + .chain_alias(&chain_id) + .and_then(|alias| NetworkName::try_from(alias).ok()) + .unwrap_or(network), + }; + + (network, package_id) + }; + + Ok(Self { + iota_client, + audit_trail_pkg_id, + network, + chain_id, + }) + } + + /// Creates a new [`AuditTrailClientReadOnly`] with a specific audit trail package ID. + /// + /// This function allows overriding the package ID lookup from the registry, which is useful + /// for connecting to networks where the package ID is known but not yet registered. + pub async fn new_with_pkg_id( + #[cfg(target_arch = "wasm32")] iota_client: WasmIotaClient, + #[cfg(not(target_arch = "wasm32"))] iota_client: IotaClient, + package_id: ObjectID, + ) -> Result { + let client = IotaClientAdapter::new(iota_client); + let network = network_id(&client).await?; + + { + let mut registry = package::audit_trail_package_registry_mut().await; + registry.insert_env_history(Env::new(network.as_ref()), vec![package_id]); + } + + Self::new_internal(client, network).await + } +} diff --git a/audit-trail-rs/src/core/mod.rs b/audit-trail-rs/src/core/mod.rs new file mode 100644 index 00000000..e1e0b086 --- /dev/null +++ b/audit-trail-rs/src/core/mod.rs @@ -0,0 +1,6 @@ +// Copyright 2020-2025 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +//! Core types and builders for audit trails. +//! +//! This module is intentionally minimal while the Move contract API is stabilized. diff --git a/audit-trail-rs/src/error.rs b/audit-trail-rs/src/error.rs new file mode 100644 index 00000000..bd8ca9c5 --- /dev/null +++ b/audit-trail-rs/src/error.rs @@ -0,0 +1,45 @@ +// Copyright 2020-2025 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use crate::iota_interaction_adapter::AdapterError; + +/// Errors that can occur when managing Audit Trails +#[derive(Debug, thiserror::Error, strum::IntoStaticStr)] +#[non_exhaustive] +pub enum Error { + /// Caused by invalid keys. + #[error("invalid key: {0}")] + InvalidKey(String), + /// Config is invalid. + #[error("invalid config: {0}")] + InvalidConfig(String), + /// An error caused by either a connection issue or an invalid RPC call. + #[error("RPC error: {0}")] + RpcError(String), + /// The provided IOTA Client returned an error + #[error("IOTA client error: {0}")] + IotaClient(#[from] AdapterError), + /// Generic error + #[error("{0}")] + GenericError(String), + /// Placeholder for unimplemented API surface. + #[error("not implemented: {0}")] + NotImplemented(&'static str), + /// Failed to parse tag + #[error("Failed to parse tag: {0}")] + FailedToParseTag(String), + /// Invalid argument + #[error("Invalid argument: {0}")] + InvalidArgument(String), + /// The response from the IOTA node API was not in the expected format. + #[error("unexpected API response: {0}")] + UnexpectedApiResponse(String), + /// Failed to deserialize data using BCS. + #[error("BCS deserialization error: {0}")] + DeserializationError(#[from] bcs::Error), +} + +#[cfg(target_arch = "wasm32")] +use product_common::impl_wasm_error_from; +#[cfg(target_arch = "wasm32")] +impl_wasm_error_from!(Error); diff --git a/audit-trail-rs/src/iota_interaction_adapter.rs b/audit-trail-rs/src/iota_interaction_adapter.rs new file mode 100644 index 00000000..7eb7e409 --- /dev/null +++ b/audit-trail-rs/src/iota_interaction_adapter.rs @@ -0,0 +1,11 @@ +// Copyright 2020-2025 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +// The following platform compile switch provides all the +// ...Adapter types from iota_interaction_rust or iota_interaction_ts +// like IotaClientAdapter, TransactionBuilderAdapter ... and so on + +#[cfg(not(target_arch = "wasm32"))] +pub(crate) use iota_interaction_rust::*; +#[cfg(target_arch = "wasm32")] +pub(crate) use iota_interaction_ts::*; diff --git a/audit-trail-rs/src/lib.rs b/audit-trail-rs/src/lib.rs new file mode 100644 index 00000000..a4ca47e5 --- /dev/null +++ b/audit-trail-rs/src/lib.rs @@ -0,0 +1,14 @@ +// Copyright 2020-2025 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +pub mod client; +pub mod core; +pub mod error; +pub(crate) mod iota_interaction_adapter; +pub(crate) mod package; + +pub use client::full_client::AuditTrailClient; +pub use client::read_only::AuditTrailClientReadOnly; +/// HTTP utilities to implement the trait [HttpClient](product_common::http_client::HttpClient). +#[cfg(feature = "gas-station")] +pub use product_common::http_client; diff --git a/audit-trail-rs/src/package.rs b/audit-trail-rs/src/package.rs new file mode 100644 index 00000000..605028b8 --- /dev/null +++ b/audit-trail-rs/src/package.rs @@ -0,0 +1,59 @@ +// Copyright 2020-2025 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +//! Package management for audit trail smart contracts. +//! +//! This module handles package ID resolution and registry management +//! for the audit trail Move contracts. + +#![allow(dead_code)] + +use std::sync::LazyLock; + +use product_common::package_registry::PackageRegistry; +use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard, TryLockError}; + +type PackageRegistryLock = RwLockReadGuard<'static, PackageRegistry>; +type PackageRegistryLockMut = RwLockWriteGuard<'static, PackageRegistry>; + +/// Global registry for audit trail package information. +static AUDIT_TRAIL_PACKAGE_REGISTRY: LazyLock> = LazyLock::new(|| { + let package_history_json = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../audit-trail-move/Move.history.json" + )); + RwLock::new( + PackageRegistry::from_package_history_json_str(package_history_json) + .expect("Move.history.json exists and it's valid"), + ) +}); + +/// Returns a read lock to the package registry. +pub(crate) async fn audit_trail_package_registry() -> PackageRegistryLock { + AUDIT_TRAIL_PACKAGE_REGISTRY.read().await +} + +/// Attempts to acquire a read lock without blocking. +pub(crate) fn try_audit_trail_package_registry() -> Result { + AUDIT_TRAIL_PACKAGE_REGISTRY.try_read() +} + +/// Returns a blocking read lock to the package registry. +pub(crate) fn blocking_audit_trail_registry() -> PackageRegistryLock { + AUDIT_TRAIL_PACKAGE_REGISTRY.blocking_read() +} + +/// Returns a write lock to the package registry. +pub(crate) async fn audit_trail_package_registry_mut() -> PackageRegistryLockMut { + AUDIT_TRAIL_PACKAGE_REGISTRY.write().await +} + +/// Attempts to acquire a write lock without blocking. +pub(crate) fn try_audit_trail_package_registry_mut() -> Result { + AUDIT_TRAIL_PACKAGE_REGISTRY.try_write() +} + +/// Returns a blocking write lock to the package registry. +pub(crate) fn blocking_audit_trail_registry_mut() -> PackageRegistryLockMut { + AUDIT_TRAIL_PACKAGE_REGISTRY.blocking_write() +} From 63d94f90b5344c8eea3b5215235930854e11902e Mon Sep 17 00:00:00 2001 From: Yasir Date: Wed, 4 Feb 2026 14:38:00 +0300 Subject: [PATCH 043/189] feat: Implement core types and structures for audit trail functionality --- audit-trail-rs/src/core/mod.rs | 6 +- audit-trail-rs/src/core/types/audit_trail.rs | 29 +++++++ audit-trail-rs/src/core/types/capability.rs | 17 ++++ audit-trail-rs/src/core/types/event.rs | 68 +++++++++++++++ audit-trail-rs/src/core/types/locking.rs | 60 ++++++++++++++ audit-trail-rs/src/core/types/metadata.rs | 11 +++ audit-trail-rs/src/core/types/mod.rs | 24 ++++++ audit-trail-rs/src/core/types/permission.rs | 82 +++++++++++++++++++ audit-trail-rs/src/core/types/record.rs | 35 ++++++++ .../src/core/types/record_correction.rs | 35 ++++++++ audit-trail-rs/src/core/types/role_map.rs | 35 ++++++++ 11 files changed, 400 insertions(+), 2 deletions(-) create mode 100644 audit-trail-rs/src/core/types/audit_trail.rs create mode 100644 audit-trail-rs/src/core/types/capability.rs create mode 100644 audit-trail-rs/src/core/types/event.rs create mode 100644 audit-trail-rs/src/core/types/locking.rs create mode 100644 audit-trail-rs/src/core/types/metadata.rs create mode 100644 audit-trail-rs/src/core/types/mod.rs create mode 100644 audit-trail-rs/src/core/types/permission.rs create mode 100644 audit-trail-rs/src/core/types/record.rs create mode 100644 audit-trail-rs/src/core/types/record_correction.rs create mode 100644 audit-trail-rs/src/core/types/role_map.rs diff --git a/audit-trail-rs/src/core/mod.rs b/audit-trail-rs/src/core/mod.rs index e1e0b086..ad7fe9a5 100644 --- a/audit-trail-rs/src/core/mod.rs +++ b/audit-trail-rs/src/core/mod.rs @@ -2,5 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 //! Core types and builders for audit trails. -//! -//! This module is intentionally minimal while the Move contract API is stabilized. + +pub mod types; + +pub use types::*; diff --git a/audit-trail-rs/src/core/types/audit_trail.rs b/audit-trail-rs/src/core/types/audit_trail.rs new file mode 100644 index 00000000..d3ed39ca --- /dev/null +++ b/audit-trail-rs/src/core/types/audit_trail.rs @@ -0,0 +1,29 @@ +// Copyright 2020-2025 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use iota_interaction::types::base_types::IotaAddress; +use iota_interaction::types::id::UID; +use serde::{Deserialize, Serialize}; + +use super::locking::LockingConfig; +use super::metadata::ImmutableMetadata; +use super::permission::Permission; +use super::record::Record; +use super::role_map::RoleMap; + +/// An audit trail stored on-chain. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct AuditTrail { + pub id: UID, + pub creator: IotaAddress, + pub created_at: u64, + pub sequence_number: u64, + pub records: Vec>, + pub locking_config: LockingConfig, + pub roles: RoleMap, + pub immutable_metadata: Option, + pub updatable_metadata: Option, + pub version: u64, + #[serde(skip)] + pub _phantom: std::marker::PhantomData, +} diff --git a/audit-trail-rs/src/core/types/capability.rs b/audit-trail-rs/src/core/types/capability.rs new file mode 100644 index 00000000..e3957192 --- /dev/null +++ b/audit-trail-rs/src/core/types/capability.rs @@ -0,0 +1,17 @@ +// Copyright 2020-2025 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use iota_interaction::types::base_types::{IotaAddress, ObjectID}; +use iota_interaction::types::id::UID; +use serde::{Deserialize, Serialize}; + +/// Capability data returned by the Move capability module. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Capability { + pub id: UID, + pub security_vault_id: ObjectID, + pub role: String, + pub issued_to: Option, + pub valid_from: Option, + pub valid_until: Option, +} diff --git a/audit-trail-rs/src/core/types/event.rs b/audit-trail-rs/src/core/types/event.rs new file mode 100644 index 00000000..6e8e3291 --- /dev/null +++ b/audit-trail-rs/src/core/types/event.rs @@ -0,0 +1,68 @@ +// Copyright 2020-2025 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use iota_interaction::types::base_types::{IotaAddress, ObjectID}; +use serde::{Deserialize, Serialize}; + +/// Generic wrapper for audit trail events. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Event { + #[serde(flatten)] + pub data: D, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AuditTrailCreated { + pub trail_id: ObjectID, + pub creator: IotaAddress, + pub timestamp: u64, + pub has_initial_record: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AuditTrailDeleted { + pub trail_id: ObjectID, + pub timestamp: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RecordAdded { + pub trail_id: ObjectID, + pub sequence_number: u64, + pub added_by: IotaAddress, + pub timestamp: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RecordDeleted { + pub trail_id: ObjectID, + pub sequence_number: u64, + pub deleted_by: IotaAddress, + pub timestamp: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CapabilityIssued { + pub security_vault_id: ObjectID, + pub capability_id: ObjectID, + pub role: String, + pub issued_to: Option, + pub valid_from: Option, + pub valid_until: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CapabilityDestroyed { + pub security_vault_id: ObjectID, + pub capability_id: ObjectID, + pub role: String, + pub issued_to: Option, + pub valid_from: Option, + pub valid_until: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CapabilityRevoked { + pub security_vault_id: ObjectID, + pub capability_id: ObjectID, +} diff --git a/audit-trail-rs/src/core/types/locking.rs b/audit-trail-rs/src/core/types/locking.rs new file mode 100644 index 00000000..fa650459 --- /dev/null +++ b/audit-trail-rs/src/core/types/locking.rs @@ -0,0 +1,60 @@ +// Copyright 2020-2025 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use serde::{Deserialize, Serialize}; + +/// Defines a locking window (time or count based). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LockingWindow { + pub time_window_seconds: Option, + pub count_window: Option, +} + +impl LockingWindow { + pub fn none() -> Self { + Self { + time_window_seconds: None, + count_window: None, + } + } + + pub fn time_based(seconds: u64) -> Self { + Self { + time_window_seconds: Some(seconds), + count_window: None, + } + } + + pub fn count_based(count: u64) -> Self { + Self { + time_window_seconds: None, + count_window: Some(count), + } + } +} + +/// Locking configuration for the audit trail. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LockingConfig { + pub delete_record_lock: LockingWindow, +} + +impl LockingConfig { + pub fn none() -> Self { + Self { + delete_record_lock: LockingWindow::none(), + } + } + + pub fn time_based(seconds: u64) -> Self { + Self { + delete_record_lock: LockingWindow::time_based(seconds), + } + } + + pub fn count_based(count: u64) -> Self { + Self { + delete_record_lock: LockingWindow::count_based(count), + } + } +} diff --git a/audit-trail-rs/src/core/types/metadata.rs b/audit-trail-rs/src/core/types/metadata.rs new file mode 100644 index 00000000..49fc9892 --- /dev/null +++ b/audit-trail-rs/src/core/types/metadata.rs @@ -0,0 +1,11 @@ +// Copyright 2020-2025 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use serde::{Deserialize, Serialize}; + +/// Metadata set at trail creation and never updated. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ImmutableMetadata { + pub name: String, + pub description: Option, +} diff --git a/audit-trail-rs/src/core/types/mod.rs b/audit-trail-rs/src/core/types/mod.rs new file mode 100644 index 00000000..4518bdb7 --- /dev/null +++ b/audit-trail-rs/src/core/types/mod.rs @@ -0,0 +1,24 @@ +// Copyright 2020-2025 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +//! Core data types for audit trails. + +pub mod audit_trail; +pub mod capability; +pub mod event; +pub mod locking; +pub mod metadata; +pub mod permission; +pub mod record; +pub mod record_correction; +pub mod role_map; + +pub use audit_trail::*; +pub use capability::*; +pub use event::*; +pub use locking::*; +pub use metadata::*; +pub use permission::*; +pub use record::*; +pub use record_correction::*; +pub use role_map::*; diff --git a/audit-trail-rs/src/core/types/permission.rs b/audit-trail-rs/src/core/types/permission.rs new file mode 100644 index 00000000..24d4d128 --- /dev/null +++ b/audit-trail-rs/src/core/types/permission.rs @@ -0,0 +1,82 @@ +// Copyright 2020-2025 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use serde::{Deserialize, Serialize}; + +/// Permission enum matching the Move permission module. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)] +pub enum Permission { + DeleteAuditTrail, + AddRecord, + DeleteRecord, + CorrectRecord, + UpdateLockingConfig, + UpdateLockingConfigForDeleteRecord, + UpdateLockingConfigForDeleteTrail, + AddRoles, + UpdateRoles, + DeleteRoles, + AddCapabilities, + RevokeCapabilities, + UpdateMetadata, + DeleteMetadata, +} + +/// Convenience wrapper for permission sets. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PermissionSet { + pub permissions: Vec, +} + +impl PermissionSet { + pub fn empty() -> Self { + Self { permissions: vec![] } + } + + pub fn from_vec(permissions: Vec) -> Self { + Self { permissions } + } + + pub fn admin_permissions() -> Self { + Self::from_vec(vec![ + Permission::DeleteAuditTrail, + Permission::AddCapabilities, + Permission::RevokeCapabilities, + Permission::AddRoles, + Permission::UpdateRoles, + Permission::DeleteRoles, + ]) + } + + pub fn record_admin_permissions() -> Self { + Self::from_vec(vec![ + Permission::AddRecord, + Permission::DeleteRecord, + Permission::CorrectRecord, + ]) + } + + pub fn locking_admin_permissions() -> Self { + Self::from_vec(vec![ + Permission::UpdateLockingConfig, + Permission::UpdateLockingConfigForDeleteTrail, + Permission::UpdateLockingConfigForDeleteRecord, + ]) + } + + pub fn role_admin_permissions() -> Self { + Self::from_vec(vec![ + Permission::AddRoles, + Permission::UpdateRoles, + Permission::DeleteRoles, + ]) + } + + pub fn cap_admin_permissions() -> Self { + Self::from_vec(vec![Permission::AddCapabilities, Permission::RevokeCapabilities]) + } + + pub fn metadata_admin_permissions() -> Self { + Self::from_vec(vec![Permission::UpdateMetadata, Permission::DeleteMetadata]) + } +} diff --git a/audit-trail-rs/src/core/types/record.rs b/audit-trail-rs/src/core/types/record.rs new file mode 100644 index 00000000..a8b0dd60 --- /dev/null +++ b/audit-trail-rs/src/core/types/record.rs @@ -0,0 +1,35 @@ +// Copyright 2020-2025 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use iota_interaction::types::base_types::IotaAddress; +use serde::{Deserialize, Serialize}; + +use super::record_correction::RecordCorrection; + +/// Supported record data types. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum RecordData { + Bytes(Vec), + Text(String), +} + +impl RecordData { + pub fn bytes(data: impl Into>) -> Self { + Self::Bytes(data.into()) + } + + pub fn text(data: impl Into) -> Self { + Self::Text(data.into()) + } +} + +/// A single record in the audit trail. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Record { + pub data: D, + pub metadata: Option, + pub sequence_number: u64, + pub added_by: IotaAddress, + pub added_at: u64, + pub correction: RecordCorrection, +} diff --git a/audit-trail-rs/src/core/types/record_correction.rs b/audit-trail-rs/src/core/types/record_correction.rs new file mode 100644 index 00000000..841c5a05 --- /dev/null +++ b/audit-trail-rs/src/core/types/record_correction.rs @@ -0,0 +1,35 @@ +// Copyright 2020-2025 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use serde::{Deserialize, Serialize}; + +/// Bidirectional correction tracking for audit records. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RecordCorrection { + pub replaces: Vec, + pub is_replaced_by: Option, +} + +impl RecordCorrection { + pub fn new() -> Self { + Self { + replaces: Vec::new(), + is_replaced_by: None, + } + } + + pub fn with_replaces(replaces: Vec) -> Self { + Self { + replaces, + is_replaced_by: None, + } + } + + pub fn is_correction(&self) -> bool { + !self.replaces.is_empty() + } + + pub fn is_replaced(&self) -> bool { + self.is_replaced_by.is_some() + } +} diff --git a/audit-trail-rs/src/core/types/role_map.rs b/audit-trail-rs/src/core/types/role_map.rs new file mode 100644 index 00000000..319ee944 --- /dev/null +++ b/audit-trail-rs/src/core/types/role_map.rs @@ -0,0 +1,35 @@ +// Copyright 2020-2025 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use iota_interaction::types::base_types::ObjectID; +use serde::{Deserialize, Serialize}; + +use super::permission::Permission; + +/// Defines the permissions required to administer roles in this RoleMap. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RoleAdminPermissions { + pub add: Permission, + pub delete: Permission, + pub update: Permission, +} + +/// Defines the permissions required to administer capabilities in this RoleMap. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CapabilityAdminPermissions { + pub add: Permission, + pub revoke: Permission, +} + +/// A simplified Rust representation of the on-chain RoleMap. +/// +/// Note: The Move type uses VecMap/VecSet; this struct represents those +/// collections as Rust vectors for convenience. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RoleMap { + pub security_vault_id: ObjectID, + pub roles: Vec<(String, Vec)>, + pub issued_capabilities: Vec, + pub role_admin_permissions: RoleAdminPermissions, + pub capability_admin_permissions: CapabilityAdminPermissions, +} From fe3656cbd25982700d28ac69c5dcdc5b7815210f Mon Sep 17 00:00:00 2001 From: Yasir Date: Thu, 5 Feb 2026 10:43:30 +0300 Subject: [PATCH 044/189] feat: Rename package name from "audit-trails" to "audit_trails" --- audit-trail-rs/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/audit-trail-rs/Cargo.toml b/audit-trail-rs/Cargo.toml index 88d6ebb1..8d7c5281 100644 --- a/audit-trail-rs/Cargo.toml +++ b/audit-trail-rs/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "audit-trails" +name = "audit_trails" version = "0.1.0-alpha" authors.workspace = true edition.workspace = true From 398b515de19d47cb74b6e985f74cd31f255a6437 Mon Sep 17 00:00:00 2001 From: Yasir Date: Thu, 5 Feb 2026 11:31:16 +0300 Subject: [PATCH 045/189] feat: Change 'replaces' field type from Vec to HashSet in RecordCorrection --- audit-trail-rs/src/core/types/record_correction.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/audit-trail-rs/src/core/types/record_correction.rs b/audit-trail-rs/src/core/types/record_correction.rs index 841c5a05..750359fe 100644 --- a/audit-trail-rs/src/core/types/record_correction.rs +++ b/audit-trail-rs/src/core/types/record_correction.rs @@ -1,24 +1,26 @@ // Copyright 2020-2025 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +use std::collections::HashSet; + use serde::{Deserialize, Serialize}; /// Bidirectional correction tracking for audit records. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RecordCorrection { - pub replaces: Vec, + pub replaces: HashSet, pub is_replaced_by: Option, } impl RecordCorrection { pub fn new() -> Self { Self { - replaces: Vec::new(), + replaces: HashSet::new(), is_replaced_by: None, } } - pub fn with_replaces(replaces: Vec) -> Self { + pub fn with_replaces(replaces: HashSet) -> Self { Self { replaces, is_replaced_by: None, From a6cadda97f1b1aca6fa746ac7b59a0d8f8d11009 Mon Sep 17 00:00:00 2001 From: Christof Gerritsma Date: Thu, 5 Feb 2026 12:26:08 +0100 Subject: [PATCH 046/189] Feat: Audit Trail uses RoleMap and Capability from product-core (#184) Audit Trail `RoleMap` & `Capability` integration and the Notarization integration for `TimeLock` has been migrated to use `TfComponents` as the following move modules have been moved from the Notarization repository to the new `TfComponents` package in `product_core` : * audit_trail::role_map * audit_trail::capability * notarization::timelock More details can be found in the product-core PR: https://github.com/iotaledger/product-core/pull/89 The Rust and WASM bindings for `TimeLock` are still located in the `notarization` repository. These will be moved to `product-core` during future AT development. Additionally, the Move type `TimeLock` which is now used from `product-core` package `TsComponents` offers new additionally variants `UnlockAtMs` and `Infinite`. The Rust and WASM bindings have been extended accordingly. --- audit-trail-move/Move.toml | 1 + audit-trail-move/sources/audit_trail.move | 3 +- audit-trail-move/sources/capability.move | 203 ------ audit-trail-move/sources/role_map.move | 601 ------------------ audit-trail-move/tests/capability_tests.move | 473 +++++--------- .../tests/create_audit_trail_tests.move | 6 +- audit-trail-move/tests/locking_tests.move | 153 +++-- audit-trail-move/tests/metadata_tests.move | 48 +- audit-trail-move/tests/record_tests.move | 136 ++-- audit-trail-move/tests/role_tests.move | 98 ++- audit-trail-move/tests/test_utils.move | 91 ++- .../notarization_wasm/src/wasm_time_lock.rs | 38 +- notarization-move/Move.lock | 42 +- notarization-move/Move.toml | 1 + .../sources/dynamic_notarization.move | 3 +- .../sources/locked_notarization.move | 3 +- notarization-move/sources/notarization.move | 6 +- notarization-move/sources/timelock.move | 127 ---- .../tests/dynamic_notarization_tests.move | 3 +- .../tests/locked_notarization_tests.move | 3 +- .../tests/notarization_tests.move | 3 +- notarization-move/tests/timelock_tests.move | 202 ------ notarization-rs/src/core/types/timelock.rs | 73 ++- 23 files changed, 590 insertions(+), 1727 deletions(-) delete mode 100644 audit-trail-move/sources/capability.move delete mode 100644 audit-trail-move/sources/role_map.move delete mode 100644 notarization-move/sources/timelock.move delete mode 100644 notarization-move/tests/timelock_tests.move diff --git a/audit-trail-move/Move.toml b/audit-trail-move/Move.toml index a66b8700..61ee5a67 100644 --- a/audit-trail-move/Move.toml +++ b/audit-trail-move/Move.toml @@ -3,6 +3,7 @@ name = "audit_trail" edition = "2024.beta" [dependencies] +TfComponents = { git = "https://github.com/iotaledger/product-core.git", subdir = "components_move", rev = "feat/role-map" } [addresses] audit_trail = "0x0" diff --git a/audit-trail-move/sources/audit_trail.move b/audit-trail-move/sources/audit_trail.move index 74cda053..7f492dba 100644 --- a/audit-trail-move/sources/audit_trail.move +++ b/audit-trail-move/sources/audit_trail.move @@ -8,15 +8,14 @@ module audit_trail::main; use audit_trail::{ - capability::Capability, locking::{Self, LockingConfig, LockingWindow, set_delete_record_lock}, permission::{Self, Permission}, record::{Self, Record}, record_correction, - role_map::{Self, RoleMap} }; use iota::{clock::{Self, Clock}, event, linked_table::{Self, LinkedTable}}; use std::string::String; +use tf_components::{capability::Capability, role_map::{Self, RoleMap}}; // ===== Errors ===== #[error] diff --git a/audit-trail-move/sources/capability.move b/audit-trail-move/sources/capability.move deleted file mode 100644 index d793a064..00000000 --- a/audit-trail-move/sources/capability.move +++ /dev/null @@ -1,203 +0,0 @@ -// Copyright (c) 2025 IOTA Stiftung -// SPDX-License-Identifier: Apache-2.0 - -/// Role-based access control capabilities for audit trails -module audit_trail::capability; - -use iota::clock::{Self, Clock}; -use std::string::String; - -// ===== Errors ===== - -#[error] -const EValidityPeriodInconsistent: vector = - b"Validity period is inconsistent: valid_from must be before valid_until"; - -// ===== Core Structures ===== - -/// Capability granting role-based access to a managed onchain object (i.e. an audit trail) -public struct Capability has key, store { - id: UID, - /// The ID of the onchain object this capability applies to - security_vault_id: ID, - /// The role granted by this capability - /// Arbitrary string specifying a role contained in the `role_map::RoleMap` mapping - role: String, - /// For whom has this capability been issued - /// * If Some(address), the capability is bound to that specific address - /// * If None, the capability is not bound to a specific address - issued_to: Option
, - /// Optional validity period start timestamp (in seconds since Unix epoch) - /// * The specified timestamp is included in the validity period - /// * If None, the capability is valid from creation time - valid_from: Option, - /// Optional validity period end timestamp (in seconds since Unix epoch) - /// * The specified timestamp is excluded in the validity period - /// * If None, the capability does not expire - valid_until: Option, -} - -/// Create a new capability with a specific role and all available optional restrictions -/// -/// Parameters: -/// * role: The role granted by this capability -/// * security_vault_id: The ID of onchain object (i.e. an audit trail) this capability applies to -/// * issued_to: Optional address restriction; if Some(address), the capability is bound to that specific address -/// * valid_from: Optional validity period start timestamp (in seconds since Unix epoch); if Some(ts), the capability is valid from that timestamp onwards -/// * valid_until: Optional validity period end timestamp (in seconds since Unix epoch); if Some(ts), the capability is valid until that timestamp -/// * ctx: The transaction context -/// -/// Returns: The newly created Capability -/// -/// Errors: -/// * EValidityPeriodInconsistent: If both valid_from and valid_until are provided and valid_from >= valid_until -public(package) fun new_capability( - role: String, - security_vault_id: ID, - issued_to: Option
, - valid_from: Option, - valid_until: Option, - ctx: &mut TxContext, -): Capability { - if (valid_from.is_some() && valid_until.is_some()) { - let from = valid_from.borrow(); - let until = valid_until.borrow(); - assert!(*from < *until, EValidityPeriodInconsistent); - }; - Capability { - id: object::new(ctx), - role, - security_vault_id, - issued_to, - valid_from, - valid_until, - } -} - -/// Create a new unrestricted capability with a specific role -public(package) fun new_capability_without_restrictions( - role: String, - security_vault_id: ID, - ctx: &mut TxContext, -): Capability { - Capability { - id: object::new(ctx), - role, - security_vault_id, - issued_to: std::option::none(), - valid_from: std::option::none(), - valid_until: std::option::none(), - } -} - -/// Create a new capability with a specific role and validity period, valid until the given timestamp -public(package) fun new_capability_valid_until( - role: String, - security_vault_id: ID, - valid_until: u64, - ctx: &mut TxContext, -): Capability { - Capability { - id: object::new(ctx), - role, - security_vault_id, - issued_to: std::option::none(), - valid_from: std::option::none(), - valid_until: std::option::some(valid_until), - } -} - -/// Create a new capability with a specific role, exclusively usable by a specific address and an optional -/// validity period, valid until the given timestamp -public(package) fun new_capability_for_address( - role: String, - security_vault_id: ID, - issued_to: address, - valid_until: Option, - ctx: &mut TxContext, -): Capability { - Capability { - id: object::new(ctx), - role, - security_vault_id, - issued_to: std::option::some(issued_to), - valid_from: std::option::none(), - valid_until, - } -} - -/// Get the capability's ID -public fun id(cap: &Capability): ID { - object::uid_to_inner(&cap.id) -} - -/// Get the capability's role -public fun role(cap: &Capability): &String { - &cap.role -} - -/// Get the capability's security_vault_id -public fun security_vault_id(cap: &Capability): ID { - cap.security_vault_id -} - -/// Check if the capability has a specific role -public fun has_role(cap: &Capability, role: &String): bool { - &cap.role == role -} - -// Get the capability's issued_to address -public fun issued_to(cap: &Capability): &Option
{ - &cap.issued_to -} - -// Get the capability's valid_from timestamp -public fun valid_from(cap: &Capability): &Option { - &cap.valid_from -} - -// Get the capability's valid_until timestamp -public fun valid_until(cap: &Capability): &Option { - &cap.valid_until -} - -// Check if the capability is currently valid for `clock::timestamp_ms(clock)` -public fun is_currently_valid(cap: &Capability, clock: &Clock): bool { - let current_ts = clock::timestamp_ms(clock) / 1000; // convert to seconds - cap.is_valid_for_timestamp(current_ts) -} - -// Check if the capability is valid for a specific timestamp (in seconds since Unix epoch) -public fun is_valid_for_timestamp(cap: &Capability, timestamp_secs: u64): bool { - let valid_from_ok = if (cap.valid_from.is_some()) { - let from = cap.valid_from.borrow(); - timestamp_secs >= *from - } else { - true - }; - let valid_until_ok = if (cap.valid_until.is_some()) { - let until = cap.valid_until.borrow(); - timestamp_secs < *until - } else { - true - }; - valid_from_ok && valid_until_ok -} - -/// Destroy a capability -public(package) fun destroy(cap: Capability) { - let Capability { - id, - role: _role, - security_vault_id: _trail_id, - issued_to: _issued_to, - valid_from: _valid_from, - valid_until: _valid_until, - } = cap; - object::delete(id); -} - -#[test_only] -public fun destroy_for_testing(cap: Capability) { - destroy(cap); -} diff --git a/audit-trail-move/sources/role_map.move b/audit-trail-move/sources/role_map.move deleted file mode 100644 index 5416969b..00000000 --- a/audit-trail-move/sources/role_map.move +++ /dev/null @@ -1,601 +0,0 @@ -// Copyright (c) 2026 IOTA Stiftung -// SPDX-License-Identifier: Apache-2.0 - -/// A role-based access control helper mapping unique role identifiers to their associated permissions. -/// -/// Provides the following functionalities: -/// - Define an initial role with a custom set of permissions (i.e. an Admin role). -/// - Use custom permission types defined by the integrating module using the generic parameter `P`. -/// - Create, delete, and update roles and their permissions -/// - Issue, revoke, and destroy `audit_trail::capability`s associated with a specific role. -/// - Validate `audit_trail::capability`s against the defined roles to facilitate proper access control by other modules -/// (function `RoleMap.is_capability_valid()`) -/// - All functions are access restricted by custom permissions defined during `RoleMap` instantiation. -/// -/// Examples: -/// - audit_trail::main module uses `RoleMap` to manage access to the audit trail records and their operations. - -module audit_trail::role_map; - -use audit_trail::capability::{Self, Capability}; -use iota::{clock::Clock, event, vec_map::{Self, VecMap}, vec_set::{Self, VecSet}}; -use std::string::String; - -// =============== Errors ========================================================== - -#[error] -const EPermissionDenied: vector = - b"The role associated with the provided capability does not have the required permission"; -#[error] -const ERoleDoesNotExist: vector = - b"The specified role, directly specified or specified by a capability, does not exist in the `RoleMap` mapping"; -#[error] -const ECapabilityHasBeenRevoked: vector = - b"The provided capability has been revoked and is no longer valid"; -#[error] -const ECapabilitySecurityVaultIdMismatch: vector = - b"The security_vault_id associated with the provided capability does not match the security_vault_id of the `RoleMap`"; -#[error] -const ECapabilityTimeConstraintsNotMet: vector = - b"The capability's time constraints are not currently met either due to `valid_from` or `valid_until` restrictions"; -#[error] -const ECapabilityIssuedToMismatch: vector = - b"The capability is restricted to a specific address which does not match the caller's address"; -#[error] -const ECapabilityPermissionDenied: vector = - b"The role associated with provided capability does not have the required permission"; - -// =============== Events ========================================================== - -/// Emitted when a capability is issued -public struct CapabilityIssued has copy, drop { - security_vault_id: ID, - capability_id: ID, - role: String, - issued_to: Option
, - valid_from: Option, - valid_until: Option, -} - -/// Emitted when a capability is destroyed -public struct CapabilityDestroyed has copy, drop { - security_vault_id: ID, - capability_id: ID, - role: String, - issued_to: Option
, - valid_from: Option, - valid_until: Option, -} - -/// Emitted when a capability is revoked or destroyed -public struct CapabilityRevoked has copy, drop { - security_vault_id: ID, - capability_id: ID, -} - -// TODO: Add event for Role creation, removing, updating, etc. - -// =============== Core Types ====================================================== - -/// Defines the permissions required to administer roles in this RoleMap -public struct RoleAdminPermissions has copy, drop, store { - /// Permission required to add a new role - add: P, - /// Permission required to delete an existing role - delete: P, - /// Permission required to update permissions associated with an existing role - update: P, -} - -/// Defines the permissions required to administer capabilities in this RoleMap -public struct CapabilityAdminPermissions has copy, drop, store { - /// Permission required to add (issue) a new capability - add: P, - /// Permission required to revoke an existing capability - revoke: P, -} - -/// The RoleMap structure mapping role names to their associated permissions -/// Generic parameter P defines the permission type used by the integrating module -/// (i.e. audit_trail::Permission) -public struct RoleMap has copy, drop, store { - /// The ObjectID of the onchain object integrating this RoleMap - security_vault_id: ID, - /// Mapping of role names to their associated permissions - roles: VecMap>, - /// Whitelist of all issued capability IDs - issued_capabilities: VecSet, - /// Permissions required to administer roles in this RoleMap - role_admin_permissions: RoleAdminPermissions

, - /// Permissions required to administer capabilities in this RoleMap - capability_admin_permissions: CapabilityAdminPermissions

, -} - -// =============== Role & Capability AdminPermissions Functions ==================== - -public fun new_role_admin_permissions( - add: P, - delete: P, - update: P, -): RoleAdminPermissions

{ - RoleAdminPermissions { - add, - delete, - update, - } -} - -public fun new_capability_admin_permissions( - add: P, - revoke: P, -): CapabilityAdminPermissions

{ - CapabilityAdminPermissions { - add, - revoke, - } -} - -// =============== RoleMap Functions =============================================== - -/// Create a new RoleMap with an initial admin role -/// The initial admin role is created with the specified name and permissions -/// An initial admin capability is created and returned alongside the RoleMap -/// The initial admin capability has no restrictions (no address, valid_from, or valid_until) -/// The security_vault_id is associated with both the RoleMap and the initial admin capability -/// Returns the newly created RoleMap and the initial admin capability -/// -/// Parameters -/// ---------- -/// - security_vault_id: -/// The security_vault_id to associate this RoleMap with the initial admin capability -/// and all other created capabilities. Set this to the ID of the onchain object that integrates the RoleMap. -/// - initial_admin_role_name: -/// The name of the initial admin role -/// - initial_admin_role_permissions: -/// The permissions associated with the initial admin role -/// - role_admin_permissions: -/// The permissions required to administer roles in this RoleMap -/// - capability_admin_permissions: -/// The permissions required to administer capabilities in this RoleMap -/// - ctx: -/// The transaction context for capability creation -public fun new( - security_vault_id: ID, - initial_admin_role_name: String, - initial_admin_role_permissions: VecSet

, - role_admin_permissions: RoleAdminPermissions

, - capability_admin_permissions: CapabilityAdminPermissions

, - ctx: &mut TxContext, -): (RoleMap

, Capability) { - let mut roles = vec_map::empty>(); - roles.insert(initial_admin_role_name, initial_admin_role_permissions); - - let admin_cap = capability::new_capability_without_restrictions( - initial_admin_role_name, - security_vault_id, - ctx, - ); - let mut issued_capabilities = vec_set::empty(); - issued_capabilities.insert(admin_cap.id()); - let role_map = RoleMap { - roles, - role_admin_permissions, - capability_admin_permissions, - security_vault_id, - issued_capabilities, - }; - - (role_map, admin_cap) -} - -/// Get the permissions associated with a specific role. -/// Aborts with ERoleDoesNotExist if the role does not exist. -public fun get_role_permissions(role_map: &RoleMap

, role: &String): &VecSet

{ - assert!(vec_map::contains(&role_map.roles, role), ERoleDoesNotExist); - vec_map::get(&role_map.roles, role) -} - -/// Create a new role consisting of a role name and associated permissions -public fun create_role( - role_map: &mut RoleMap

, - cap: &Capability, - role: String, - permissions: VecSet

, - clock: &Clock, - ctx: &TxContext, -) { - assert!( - role_map.is_capability_valid( - cap, - &role_map.role_admin_permissions.add, - clock, - ctx, - ), - EPermissionDenied, - ); - - vec_map::insert(&mut role_map.roles, role, permissions); -} - -/// Delete an existing role -public fun delete_role( - role_map: &mut RoleMap

, - cap: &Capability, - role: &String, - clock: &Clock, - ctx: &TxContext, -) { - assert!( - role_map.is_capability_valid( - cap, - &role_map.role_admin_permissions.delete, - clock, - ctx, - ), - EPermissionDenied, - ); - - vec_map::remove(&mut role_map.roles, role); -} - -/// Update permissions associated with an existing role -public fun update_role_permissions( - role_map: &mut RoleMap

, - cap: &Capability, - role: &String, - new_permissions: VecSet

, - clock: &Clock, - ctx: &TxContext, -) { - assert!( - role_map.is_capability_valid( - cap, - &role_map.role_admin_permissions.update, - clock, - ctx, - ), - EPermissionDenied, - ); - - assert!(vec_map::contains(&role_map.roles, role), ERoleDoesNotExist); - vec_map::remove(&mut role_map.roles, role); - vec_map::insert(&mut role_map.roles, *role, new_permissions); -} - -/// Indicates if the specified role exists in the role_map -public fun has_role(role_map: &RoleMap

, role: &String): bool { - vec_map::contains(&role_map.roles, role) -} - -// =============== Capability related Functions ==================================== - -/// Indicates if a provided capability is valid. -/// -/// A capability is considered valid if: -/// - The capability's security_vault_id matches the RoleMap's security_vault_id. -/// Aborts with ECapabilitySecurityVaultIdMismatch if not matching. -/// - The role value specified by the capability exists in the `RoleMap` mapping. -/// Aborts with ERoleDoesNotExist if the role does not exist. -/// - The role associated with the capability contains the permission specified by the `permission` argument. -/// Aborts with ECapabilityPermissionDenied if the permission is not granted by the role. -/// - The capability has not been revoked (is included in the `issued_capabilities` set). -/// Aborts with ECapabilityHasBeenRevoked if revoked. -/// - The capability is currently active, based on its time restrictions (if any). -/// Aborts with ECapabilityTimeConstraintsNotMet, if the current time is outside the valid_from and valid_until range. -/// - If the capability is restricted to a specific address, the caller's address matches the sender of the transaction. -/// Aborts with ECapabilityIssuedToMismatch if the addresses do not match. -/// -/// Parameters -/// ---------- -/// - role_map: Reference to the `RoleMap` mapping. -/// - cap: Reference to the capability to be validated. -/// - permission: The permission to check against the capability's role. -/// - clock: Reference to a Clock instance for time-based validation. -/// - ctx: Reference to the transaction context for accessing the caller's address. -/// -/// Returns -/// ------- -/// - bool: true if the capability is valid, otherwise aborts with the relevant error. -public fun is_capability_valid( - role_map: &RoleMap

, - cap: &Capability, - permission: &P, - clock: &Clock, - ctx: &TxContext, -): bool { - assert!( - role_map.security_vault_id == cap.security_vault_id(), - ECapabilitySecurityVaultIdMismatch, - ); - - let permissions = role_map.get_role_permissions(cap.role()); - assert!(vec_set::contains(permissions, permission), ECapabilityPermissionDenied); - - assert!(role_map.issued_capabilities.contains(&cap.id()), ECapabilityHasBeenRevoked); - - if (cap.valid_from().is_some() || cap.valid_until().is_some()) { - assert!(cap.is_currently_valid(clock), ECapabilityTimeConstraintsNotMet); - }; - - if (cap.issued_to().is_some()) { - let caller = ctx.sender(); - let issued_to_addr = cap.issued_to().borrow(); - assert!(*issued_to_addr == caller, ECapabilityIssuedToMismatch); - }; - - true -} - -/// Create a new capability -/// -/// Parameters -/// ---------- -/// - role_map: Reference to the `RoleMap` mapping. -/// - cap: Reference to the capability used to authorize the creation of the new capability. -/// - role: The role to be assigned to the new capability. -/// - issued_to: Optional address restriction for the new capability. -/// - valid_from: Optional start time (in seconds since Unix epoch) for the new capability. -/// - valid_until: Optional end time (in seconds since Unix epoch) for the new capability. -/// - clock: Reference to a Clock instance for time-based validation. -/// - ctx: Reference to the transaction context. -/// -/// Returns the newly created capability. -/// -/// Sends a CapabilityIssued event upon successful creation. -/// -/// Errors: -/// - Aborts with EPermissionDenied if the provided capability does not have the permission specified with `CapabilityAdminPermissions::add`. -/// - Aborts with ERoleDoesNotExist if the specified role does not exist in the role_map. -/// - Aborts with audit_trail::capability::EValidityPeriodInconsistent if the provided valid_from and valid_until are inconsistent. -public fun new_capability( - role_map: &mut RoleMap

, - cap: &Capability, - role: &String, - issued_to: Option

, - valid_from: Option, - valid_until: Option, - clock: &Clock, - ctx: &mut TxContext, -): Capability { - assert!( - role_map.is_capability_valid( - cap, - &role_map.capability_admin_permissions.add, - clock, - ctx, - ), - EPermissionDenied, - ); - - assert!(role_map.roles.contains(role), ERoleDoesNotExist); - let new_cap = capability::new_capability( - *role, - role_map.security_vault_id, - issued_to, - valid_from, - valid_until, - ctx, - ); - register_new_capability(role_map, &new_cap); - new_cap -} - -/// Create a new unrestricted capability with a specific role without any -/// address, valid_from, or valid_until restrictions. -/// -/// Returns the newly created capability. -/// -/// Sends a CapabilityIssued event upon successful creation. -/// -/// Errors: -/// - Aborts with EPermissionDenied if the provided capability does not have the permission specified with `CapabilityAdminPermissions::add`. -/// - Aborts with ERoleDoesNotExist if the specified role does not exist in the role_map. -public fun new_capability_without_restrictions( - role_map: &mut RoleMap

, - cap: &Capability, - role: &String, - clock: &Clock, - ctx: &mut TxContext, -): Capability { - assert!( - role_map.is_capability_valid( - cap, - &role_map.capability_admin_permissions.add, - clock, - ctx, - ), - EPermissionDenied, - ); - - assert!(role_map.roles.contains(role), ERoleDoesNotExist); - let new_cap = capability::new_capability_without_restrictions( - *role, - role_map.security_vault_id, - ctx, - ); - - register_new_capability(role_map, &new_cap); - new_cap -} - -/// Create a new capability with a specific role that expires at a given timestamp (seconds since Unix epoch). -/// -/// Returns the newly created capability. -/// -/// Sends a CapabilityIssued event upon successful creation. -/// -/// Errors: -/// - Aborts with EPermissionDenied if the provided capability does not have the permission specified with `CapabilityAdminPermissions::add`. -/// - Aborts with ERoleDoesNotExist if the specified role does not exist in the role_map. -public fun new_capability_valid_until( - role_map: &mut RoleMap

, - cap: &Capability, - role: &String, - valid_until: u64, - clock: &Clock, - ctx: &mut TxContext, -): Capability { - assert!( - role_map.is_capability_valid( - cap, - &role_map.capability_admin_permissions.add, - clock, - ctx, - ), - EPermissionDenied, - ); - - assert!(role_map.roles.contains(role), ERoleDoesNotExist); - let new_cap = capability::new_capability_valid_until( - *role, - role_map.security_vault_id, - valid_until, - ctx, - ); - - register_new_capability(role_map, &new_cap); - new_cap -} - -/// Create a new capability with a specific role restricted to an address. -/// Optionally set an expiration time (seconds since Unix epoch). -/// -/// Returns the newly created capability. -/// -/// Sends a CapabilityIssued event upon successful creation. -/// -/// Errors: -/// - Aborts with EPermissionDenied if the provided capability does not have the permission specified with `CapabilityAdminPermissions::add`. -/// - Aborts with ERoleDoesNotExist if the specified role does not exist in the role_map. -public fun new_capability_for_address( - role_map: &mut RoleMap

, - cap: &Capability, - role: &String, - issued_to: address, - valid_until: Option, - clock: &Clock, - ctx: &mut TxContext, -): Capability { - assert!( - role_map.is_capability_valid( - cap, - &role_map.capability_admin_permissions.add, - clock, - ctx, - ), - EPermissionDenied, - ); - - assert!(role_map.roles.contains(role), ERoleDoesNotExist); - let new_cap = capability::new_capability_for_address( - *role, - role_map.security_vault_id, - issued_to, - valid_until, - ctx, - ); - - register_new_capability(role_map, &new_cap); - new_cap -} - -/// Destroy an existing capability -/// Every owner of a capability is allowed to destroy it when no longer needed. -/// -/// Sends a CapabilityDestroyed event upon successful destruction. -/// -/// TODO: Clarify if we need to restrict access with the `CapabilitiesRevoke` permission here. -/// If yes, we also need a destroy function for Admin capabilities (without the need of another Admin capability). -/// Otherwise the last Admin capability holder will block the role_map forever by not being able to destroy it. -public fun destroy_capability( - role_map: &mut RoleMap

, - cap_to_destroy: Capability, -) { - assert!( - role_map.security_vault_id == cap_to_destroy.security_vault_id(), - ECapabilitySecurityVaultIdMismatch, - ); - - if (role_map.issued_capabilities.contains(&cap_to_destroy.id())) { - // Capability has not been revoked before destroying, so let's remove it now - role_map.issued_capabilities.remove(&cap_to_destroy.id()); - }; - - event::emit(CapabilityDestroyed { - security_vault_id: role_map.security_vault_id, - capability_id: cap_to_destroy.id(), - role: *cap_to_destroy.role(), - issued_to: *cap_to_destroy.issued_to(), - valid_from: *cap_to_destroy.valid_from(), - valid_until: *cap_to_destroy.valid_until(), - }); - - cap_to_destroy.destroy(); -} - -/// Revoke an existing capability -/// -/// Sends a CapabilityRevoked event upon successful revocation. -/// -/// Errors: -/// - Aborts with EPermissionDenied if the provided capability does not have the permission specified with `CapabilityAdminPermissions::revoke`. -/// - Aborts with ERoleDoesNotExist if the specified role does not exist in the `RoleMap.issued_capabilities()` list. -public fun revoke_capability( - role_map: &mut RoleMap

, - cap: &Capability, - cap_to_revoke: ID, - clock: &Clock, - ctx: &TxContext, -) { - assert!( - role_map.is_capability_valid( - cap, - &role_map.capability_admin_permissions.revoke, - clock, - ctx, - ), - EPermissionDenied, - ); - - assert!(role_map.issued_capabilities.contains(&cap_to_revoke), ERoleDoesNotExist); - role_map.issued_capabilities.remove(&cap_to_revoke); - - event::emit(CapabilityRevoked { - security_vault_id: role_map.security_vault_id, - capability_id: cap_to_revoke, - }); -} - -fun register_new_capability(role_map: &mut RoleMap

, new_cap: &Capability) { - role_map.issued_capabilities.insert(new_cap.id()); - - event::emit(CapabilityIssued { - security_vault_id: role_map.security_vault_id, - capability_id: new_cap.id(), - role: *new_cap.role(), - issued_to: *new_cap.issued_to(), - valid_from: *new_cap.valid_from(), - valid_until: *new_cap.valid_until(), - }); -} - -// =============== Getter Functions ================================================ - -/// Returns the size of the role_map, the number of managed roles -public fun size(role_map: &RoleMap

): u64 { - vec_map::size(&role_map.roles) -} - -/// Returns the security_vault_id associated with the role_map -public fun security_vault_id(role_map: &RoleMap

): ID { - role_map.security_vault_id -} - -//Returns the role admin permissions associated with the role_map -public fun role_admin_permissions(role_map: &RoleMap

): &RoleAdminPermissions

{ - &role_map.role_admin_permissions -} - -public fun issued_capabilities(role_map: &RoleMap

): &VecSet { - &role_map.issued_capabilities -} diff --git a/audit-trail-move/tests/capability_tests.move b/audit-trail-move/tests/capability_tests.move index d4bc27cc..03a8df07 100644 --- a/audit-trail-move/tests/capability_tests.move +++ b/audit-trail-move/tests/capability_tests.move @@ -3,7 +3,6 @@ module audit_trail::capability_tests; use audit_trail::{ - capability::Capability, locking, main::AuditTrail, permission, @@ -17,6 +16,7 @@ use audit_trail::{ }; use iota::test_scenario::{Self as ts, Scenario}; use std::string; +use tf_components::capability::Capability; /// Helper function to setup an audit trail with a RecordAdmin role and a capability /// with a time window restriction transferred to the record_user. @@ -25,8 +25,8 @@ fun setup_trail_with_record_admin_capability_and_time_window_restriction( scenario: &mut Scenario, admin_user: address, record_user: address, - valid_from_secs: u64, - valid_until_secs: u64, + valid_from_ms: u64, + valid_until_ms: u64, ): ID { // Setup let trail_id = setup_trail_with_record_admin_role(scenario, admin_user); @@ -42,16 +42,16 @@ fun setup_trail_with_record_admin_capability_and_time_window_restriction( &admin_cap, &string::utf8(b"RecordAdmin"), std::option::none(), // no address restriction - std::option::some(valid_from_secs), - std::option::some(valid_until_secs), + std::option::some(valid_from_ms), + std::option::some(valid_until_ms), &clock, ts::ctx(scenario), ); // Verify capability properties assert!(cap.issued_to().is_none(), 0); - assert!(cap.valid_from() == std::option::some(valid_from_secs), 1); - assert!(cap.valid_until() == std::option::some(valid_until_secs), 2); + assert!(cap.valid_from() == std::option::some(valid_from_ms), 1); + assert!(cap.valid_until() == std::option::some(valid_until_ms), 2); transfer::public_transfer(cap, record_user); cleanup_capability_trail_and_clock(scenario, admin_cap, trail, clock); @@ -149,17 +149,15 @@ fun test_new_capability() { let initial_cap_count = trail.roles().issued_capabilities().size(); assert!(initial_cap_count == 1, 0); // Only admin cap - let cap1 = trail - .roles_mut() - .new_capability_without_restrictions( - &admin_cap, - &string::utf8(b"RecordAdmin"), - &clock, - ts::ctx(&mut scenario), - ); - + let cap1 = test_utils::new_capability_without_restrictions( + trail.roles_mut(), + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); assert!(cap1.role() == string::utf8(b"RecordAdmin"), 1); - assert!(cap1.security_vault_id() == trail_id, 2); + assert!(cap1.target_key() == trail_id, 2); let cap1_id = object::id(&cap1); @@ -180,14 +178,13 @@ fun test_new_capability() { let previous_cap_count = trail.roles().issued_capabilities().size(); - let cap2 = trail - .roles_mut() - .new_capability_without_restrictions( - &admin_cap, - &string::utf8(b"RecordAdmin"), - &clock, - ts::ctx(&mut scenario), - ); + let cap2 = test_utils::new_capability_without_restrictions( + trail.roles_mut(), + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); let cap2_id = object::id(&cap2); @@ -223,25 +220,23 @@ fun test_revoke_capability() { let (cap1_id, cap2_id) = { let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); - let cap1 = trail - .roles_mut() - .new_capability_without_restrictions( - &admin_cap, - &string::utf8(b"RecordAdmin"), - &clock, - ts::ctx(&mut scenario), - ); + let cap1 = test_utils::new_capability_without_restrictions( + trail.roles_mut(), + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); let cap1_id = object::id(&cap1); transfer::public_transfer(cap1, user1); - let cap2 = trail - .roles_mut() - .new_capability_without_restrictions( - &admin_cap, - &string::utf8(b"RecordAdmin"), - &clock, - ts::ctx(&mut scenario), - ); + let cap2 = test_utils::new_capability_without_restrictions( + trail.roles_mut(), + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); let cap2_id = object::id(&cap2); transfer::public_transfer(cap2, user2); @@ -330,25 +325,23 @@ fun test_destroy_capability() { let (cap1_id, cap2_id) = { let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); - let cap1 = trail - .roles_mut() - .new_capability_without_restrictions( - &admin_cap, - &string::utf8(b"RecordAdmin"), - &clock, - ts::ctx(&mut scenario), - ); + let cap1 = test_utils::new_capability_without_restrictions( + trail.roles_mut(), + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); let cap1_id = object::id(&cap1); transfer::public_transfer(cap1, user1); - let cap2 = trail - .roles_mut() - .new_capability_without_restrictions( - &admin_cap, - &string::utf8(b"RecordAdmin"), - &clock, - ts::ctx(&mut scenario), - ); + let cap2 = test_utils::new_capability_without_restrictions( + trail.roles_mut(), + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); let cap2_id = object::id(&cap2); transfer::public_transfer(cap2, user2); @@ -462,25 +455,23 @@ fun test_capability_lifecycle() { let (record_cap_id, role_cap_id) = { let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); - let record_cap = trail - .roles_mut() - .new_capability_without_restrictions( - &admin_cap, - &string::utf8(b"RecordAdmin"), - &clock, - ts::ctx(&mut scenario), - ); + let record_cap = test_utils::new_capability_without_restrictions( + trail.roles_mut(), + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); let record_cap_id = object::id(&record_cap); transfer::public_transfer(record_cap, record_admin_user); - let role_cap = trail - .roles_mut() - .new_capability_without_restrictions( - &admin_cap, - &string::utf8(b"RoleAdmin"), - &clock, - ts::ctx(&mut scenario), - ); + let role_cap = test_utils::new_capability_without_restrictions( + trail.roles_mut(), + &admin_cap, + &string::utf8(b"RoleAdmin"), + &clock, + ts::ctx(&mut scenario), + ); let role_cap_id = object::id(&role_cap); transfer::public_transfer(role_cap, role_admin_user); @@ -570,16 +561,15 @@ fun test_capability_issued_to_only() { { let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); - let cap = trail - .roles_mut() - .new_capability_for_address( - &admin_cap, - &string::utf8(b"RecordAdmin"), - authorized_user, - std::option::none(), // no time restriction - &clock, - ts::ctx(&mut scenario), - ); + let cap = test_utils::new_capability_for_address( + trail.roles_mut(), + &admin_cap, + &string::utf8(b"RecordAdmin"), + authorized_user, + std::option::none(), // no time restriction + &clock, + ts::ctx(&mut scenario), + ); // Verify capability properties assert!(cap.issued_to() == std::option::some(authorized_user), 0); @@ -668,14 +658,13 @@ fun test_revoked_capability_cannot_be_used() { ts::ctx(&mut scenario), ); - let user_cap = trail - .roles_mut() - .new_capability_without_restrictions( - &admin_cap, - &string::utf8(b"RecordAdmin"), - &clock, - ts::ctx(&mut scenario), - ); + let user_cap = test_utils::new_capability_without_restrictions( + trail.roles_mut(), + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); transfer::public_transfer(user_cap, user); cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); @@ -737,14 +726,13 @@ fun test_new_capability_for_nonexistent_role() { { let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); - let bad_cap = trail - .roles_mut() - .new_capability_without_restrictions( - &admin_cap, - &string::utf8(b"NonExistentRole"), - &clock, - ts::ctx(&mut scenario), - ); + let bad_cap = test_utils::new_capability_without_restrictions( + trail.roles_mut(), + &admin_cap, + &string::utf8(b"NonExistentRole"), + &clock, + ts::ctx(&mut scenario), + ); bad_cap.destroy_for_testing(); cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); @@ -798,23 +786,21 @@ fun test_revoke_capability_permission_denied() { ts::ctx(&mut scenario), ); - let user1_cap = trail - .roles_mut() - .new_capability_without_restrictions( - &admin_cap, - &string::utf8(b"NoRevokePerm"), - &clock, - ts::ctx(&mut scenario), - ); + let user1_cap = test_utils::new_capability_without_restrictions( + trail.roles_mut(), + &admin_cap, + &string::utf8(b"NoRevokePerm"), + &clock, + ts::ctx(&mut scenario), + ); - let user2_cap = trail - .roles_mut() - .new_capability_without_restrictions( - &admin_cap, - &string::utf8(b"RecordAdmin"), - &clock, - ts::ctx(&mut scenario), - ); + let user2_cap = test_utils::new_capability_without_restrictions( + trail.roles_mut(), + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); transfer::public_transfer(user1_cap, user1); transfer::public_transfer(user2_cap, user2); @@ -886,14 +872,13 @@ fun test_new_capability_permission_denied() { ts::ctx(&mut scenario), ); - let user_cap = trail - .roles_mut() - .new_capability_without_restrictions( - &admin_cap, - &string::utf8(b"NoCapPerm"), - &clock, - ts::ctx(&mut scenario), - ); + let user_cap = test_utils::new_capability_without_restrictions( + trail.roles_mut(), + &admin_cap, + &string::utf8(b"NoCapPerm"), + &clock, + ts::ctx(&mut scenario), + ); transfer::public_transfer(user_cap, user); cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); @@ -904,14 +889,13 @@ fun test_new_capability_permission_denied() { { let (user_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); - let new_cap = trail - .roles_mut() - .new_capability_without_restrictions( - &user_cap, - &string::utf8(b"RecordAdmin"), - &clock, - ts::ctx(&mut scenario), - ); + let new_cap = test_utils::new_capability_without_restrictions( + trail.roles_mut(), + &user_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); new_cap.destroy_for_testing(); cleanup_capability_trail_and_clock(&scenario, user_cap, trail, clock); @@ -1017,7 +1001,7 @@ fun test_capability_valid_until_only() { let mut scenario = ts::begin(admin_user); - let valid_until_time_secs = test_utils::initial_time_for_testing() / 1000 + 10; + let valid_until_time_ms = test_utils::initial_time_for_testing() + 10000; // Setup let _trail_id = setup_trail_with_record_admin_role(&mut scenario, admin_user); @@ -1027,20 +1011,19 @@ fun test_capability_valid_until_only() { { let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); - let cap = trail - .roles_mut() - .new_capability_valid_until( - &admin_cap, - &string::utf8(b"RecordAdmin"), - valid_until_time_secs, - &clock, - ts::ctx(&mut scenario), - ); + let cap = test_utils::new_capability_valid_until( + trail.roles_mut(), + &admin_cap, + &string::utf8(b"RecordAdmin"), + valid_until_time_ms, + &clock, + ts::ctx(&mut scenario), + ); // Verify capability properties assert!(cap.issued_to().is_none(), 0); assert!(cap.valid_from().is_none(), 1); - assert!(cap.valid_until() == std::option::some(valid_until_time_secs), 2); + assert!(cap.valid_until() == std::option::some(valid_until_time_ms), 2); transfer::public_transfer(cap, user); cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); @@ -1050,7 +1033,7 @@ fun test_capability_valid_until_only() { ts::next_tx(&mut scenario, user); { let (cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); - clock.set_for_testing(valid_until_time_secs* 1000 - 1000); + clock.set_for_testing(valid_until_time_ms - 1000000); let test_data = test_utils::new_test_data(1, b"Test record before valid_until"); trail.add_record( @@ -1068,7 +1051,7 @@ fun test_capability_valid_until_only() { ts::next_tx(&mut scenario, user); { let (cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); - clock.set_for_testing(valid_until_time_secs* 1000 + 1000); + clock.set_for_testing(valid_until_time_ms + 100000); // This should fail as the capability has expired let test_data = test_utils::new_test_data(1, b"Test record after valid_until"); @@ -1106,8 +1089,8 @@ fun test_capability_time_window() { &mut scenario, admin_user, user, - valid_from_time / 1000, - valid_until_time / 1000, + valid_from_time, + valid_until_time, ); // Use the capability within the valid time window @@ -1142,23 +1125,23 @@ fun test_capability_time_window_before_valid_from() { let mut scenario = ts::begin(admin_user); - let valid_from_time_secs = test_utils::initial_time_for_testing() / 1000 + 5; - let valid_until_time_secs = test_utils::initial_time_for_testing() / 1000 + 10; + let valid_from_time_ms = test_utils::initial_time_for_testing() + 5000; + let valid_until_time_ms = test_utils::initial_time_for_testing() + 10000; // Setup let _trail_id = setup_trail_with_record_admin_capability_and_time_window_restriction( &mut scenario, admin_user, user, - valid_from_time_secs, - valid_until_time_secs, + valid_from_time_ms, + valid_until_time_ms, ); // Use the capability before valid_from ts::next_tx(&mut scenario, user); { let (cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); - clock.set_for_testing(valid_from_time_secs* 1000 - 1000); + clock.set_for_testing(valid_from_time_ms - 1000); let test_data = test_utils::new_test_data(1, b"Test record before valid_from"); trail.add_record( @@ -1186,23 +1169,23 @@ fun test_capability_time_window_after_valid_until() { let mut scenario = ts::begin(admin_user); - let valid_from_time_secs = test_utils::initial_time_for_testing() / 1000 + 5; - let valid_until_time_secs = test_utils::initial_time_for_testing() / 1000 + 10; + let valid_from_time_ms = test_utils::initial_time_for_testing() + 5000; + let valid_until_time_ms = test_utils::initial_time_for_testing() + 10000; // Setup let _trail_id = setup_trail_with_record_admin_capability_and_time_window_restriction( &mut scenario, admin_user, user, - valid_from_time_secs, - valid_until_time_secs, + valid_from_time_ms, + valid_until_time_ms, ); // Use the capability after valid_until ts::next_tx(&mut scenario, user); { let (cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); - clock.set_for_testing(valid_until_time_secs* 1000 + 1000); + clock.set_for_testing(valid_until_time_ms + 1000); let test_data = test_utils::new_test_data(1, b"Test record after valid_until"); trail.add_record( @@ -1269,8 +1252,8 @@ fun test_is_valid_for_timestamp() { // Before valid_until (exclusive) assert!(cap.is_valid_for_timestamp(valid_until_time - 1), 3); - // At valid_until (exclusive) - assert!(!cap.is_valid_for_timestamp(valid_until_time), 4); + // At valid_until (inclusive) + assert!(cap.is_valid_for_timestamp(valid_until_time), 4); // After valid_until assert!(!cap.is_valid_for_timestamp(valid_until_time + 1), 5); @@ -1284,14 +1267,13 @@ fun test_is_valid_for_timestamp() { { let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); - let unrestricted_cap = trail - .roles_mut() - .new_capability_without_restrictions( - &admin_cap, - &string::utf8(b"RecordAdmin"), - &clock, - ts::ctx(&mut scenario), - ); + let unrestricted_cap = test_utils::new_capability_without_restrictions( + trail.roles_mut(), + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); // Should be valid at any timestamp assert!(unrestricted_cap.is_valid_for_timestamp(0), 6); @@ -1336,8 +1318,8 @@ fun test_is_currently_valid() { &admin_cap, &string::utf8(b"RecordAdmin"), std::option::none(), - std::option::some(valid_from_time / 1000), - std::option::some(valid_until_time / 1000), + std::option::some(valid_from_time), + std::option::some(valid_until_time), &clock, ts::ctx(&mut scenario), ); @@ -1387,158 +1369,3 @@ fun test_is_currently_valid() { ts::end(scenario); } - -/// Test Capability::new_capability_without_restrictions function. -/// -/// This test validates: -/// - Creates capability with no restrictions -/// - issued_to, valid_from, and valid_until are all None -/// - Capability can be used by anyone at any time -#[test] -fun test_new_capability_without_restrictions() { - let admin_user = @0xAD; - let any_user = @0xB0B; - - let mut scenario = ts::begin(admin_user); - - // Setup - let trail_id = setup_trail_with_record_admin_role(&mut scenario, admin_user); - - // Create unrestricted capability - ts::next_tx(&mut scenario, admin_user); - { - let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); - - let cap = trail - .roles_mut() - .new_capability_without_restrictions( - &admin_cap, - &string::utf8(b"RecordAdmin"), - &clock, - ts::ctx(&mut scenario), - ); - - // Verify no restrictions - assert!(cap.issued_to().is_none(), 0); - assert!(cap.valid_from().is_none(), 1); - assert!(cap.valid_until().is_none(), 2); - assert!(cap.role() == string::utf8(b"RecordAdmin"), 3); - assert!(cap.security_vault_id() == trail_id, 4); - - transfer::public_transfer(cap, any_user); - cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); - }; - - // Verify any user can use it at any time - ts::next_tx(&mut scenario, any_user); - { - let (cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); - clock.set_for_testing(999999999); - - let test_data = test_utils::new_test_data(1, b"Test"); - trail.add_record( - &cap, - test_data, - std::option::none(), - &clock, - ts::ctx(&mut scenario), - ); - - cleanup_capability_trail_and_clock(&scenario, cap, trail, clock); - }; - - ts::end(scenario); -} - -/// Test Capability::new_capability_valid_until function. -/// -/// This test validates: -/// - Creates capability with only valid_until restriction -/// - issued_to and valid_from are None -/// - Capability expires at the specified timestamp -#[test] -fun test_new_capability_valid_until() { - let admin_user = @0xAD; - let user = @0xB0B; - - let mut scenario = ts::begin(admin_user); - - let valid_until_time = test_utils::initial_time_for_testing() + 10000; - - // Setup - let trail_id = setup_trail_with_record_admin_role(&mut scenario, admin_user); - - // Create capability with valid_until - ts::next_tx(&mut scenario, admin_user); - { - let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); - - let cap = trail - .roles_mut() - .new_capability_valid_until( - &admin_cap, - &string::utf8(b"RecordAdmin"), - valid_until_time, - &clock, - ts::ctx(&mut scenario), - ); - - // Verify restrictions - assert!(cap.issued_to().is_none(), 0); - assert!(cap.valid_from().is_none(), 1); - assert!(cap.valid_until() == std::option::some(valid_until_time), 2); - assert!(cap.role() == string::utf8(b"RecordAdmin"), 3); - assert!(cap.security_vault_id() == trail_id, 4); - - transfer::public_transfer(cap, user); - cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); - }; - - ts::end(scenario); -} - -/// Test Capability::new_capability_for_address with None for valid_until. -/// -/// This test validates: -/// - Creates capability restricted to specific address -/// - valid_until is None (no expiration) -/// - valid_from is None -#[test] -fun test_new_capability_for_address_no_expiration() { - let admin_user = @0xAD; - let authorized_user = @0xB0B; - - let mut scenario = ts::begin(admin_user); - - // Setup - let trail_id = setup_trail_with_record_admin_role(&mut scenario, admin_user); - - // Create capability for address without expiration - ts::next_tx(&mut scenario, admin_user); - { - let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); - - let cap = trail - .roles_mut() - .new_capability_for_address( - &admin_cap, - &string::utf8(b"RecordAdmin"), - authorized_user, - std::option::none(), // no expiration - &clock, - ts::ctx(&mut scenario), - ); - - // Verify restrictions - assert!(cap.issued_to() == std::option::some(authorized_user), 0); - assert!(cap.valid_from().is_none(), 1); - assert!(cap.valid_until().is_none(), 2); - assert!(cap.role() == string::utf8(b"RecordAdmin"), 3); - assert!(cap.security_vault_id() == trail_id, 4); - - transfer::public_transfer(cap, authorized_user); - cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); - }; - - ts::end(scenario); -} diff --git a/audit-trail-move/tests/create_audit_trail_tests.move b/audit-trail-move/tests/create_audit_trail_tests.move index 7c07ce5d..5707c08d 100644 --- a/audit-trail-move/tests/create_audit_trail_tests.move +++ b/audit-trail-move/tests/create_audit_trail_tests.move @@ -33,7 +33,7 @@ fun test_create_without_initial_record() { // Verify capability was created assert!(admin_cap.role() == initial_admin_role_name(), 0); - assert!(admin_cap.security_vault_id() == trail_id, 1); + assert!(admin_cap.target_key() == trail_id, 1); // Clean up admin_cap.destroy_for_testing(); @@ -71,7 +71,7 @@ fun test_create_with_initial_record() { // Verify capability assert!(admin_cap.role() == initial_admin_role_name(), 0); - assert!(admin_cap.security_vault_id() == trail_id, 1); + assert!(admin_cap.target_key() == trail_id, 1); // Clean up admin_cap.destroy_for_testing(); @@ -230,7 +230,7 @@ fun test_create_metadata_admin_role() { // Verify admin capability was created assert!(admin_cap.role() == initial_admin_role_name(), 0); - assert!(admin_cap.security_vault_id() == trail_id, 1); + assert!(admin_cap.target_key() == trail_id, 1); // Transfer the admin capability to the user transfer::public_transfer(admin_cap, user); diff --git a/audit-trail-move/tests/locking_tests.move b/audit-trail-move/tests/locking_tests.move index 7a81e714..c6d84b57 100644 --- a/audit-trail-move/tests/locking_tests.move +++ b/audit-trail-move/tests/locking_tests.move @@ -3,11 +3,11 @@ module audit_trail::locking_tests; use audit_trail::{ - capability::Capability, locking, main::AuditTrail, permission, test_utils::{ + Self, TestData, setup_test_audit_trail, new_test_data, @@ -19,6 +19,7 @@ use audit_trail::{ }; use iota::{clock, test_scenario as ts}; use std::string; +use tf_components::capability::Capability; // ===== Time-Based Locking Tests ===== @@ -131,14 +132,13 @@ fun test_count_based_locking() { ts::ctx(&mut scenario), ); - let record_cap = trail - .roles_mut() - .new_capability_without_restrictions( - &admin_cap, - &string::utf8(b"RecordAdmin"), - &clock, - ts::ctx(&mut scenario), - ); + let record_cap = test_utils::new_capability_without_restrictions( + trail.roles_mut(), + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); transfer::public_transfer(record_cap, admin); admin_cap.destroy_for_testing(); @@ -281,14 +281,13 @@ fun test_update_locking_config() { ts::ctx(&mut scenario), ); - let locking_cap = trail - .roles_mut() - .new_capability_without_restrictions( - &admin_cap, - &string::utf8(b"LockingAdmin"), - &clock, - ts::ctx(&mut scenario), - ); + let locking_cap = test_utils::new_capability_without_restrictions( + trail.roles_mut(), + &admin_cap, + &string::utf8(b"LockingAdmin"), + &clock, + ts::ctx(&mut scenario), + ); transfer::public_transfer(locking_cap, admin); admin_cap.destroy_for_testing(); @@ -354,14 +353,13 @@ fun test_update_locking_config_permission_denied() { ts::ctx(&mut scenario), ); - let no_locking_cap = trail - .roles_mut() - .new_capability_without_restrictions( - &admin_cap, - &string::utf8(b"NoLockingPerm"), - &clock, - ts::ctx(&mut scenario), - ); + let no_locking_cap = test_utils::new_capability_without_restrictions( + trail.roles_mut(), + &admin_cap, + &string::utf8(b"NoLockingPerm"), + &clock, + ts::ctx(&mut scenario), + ); transfer::public_transfer(no_locking_cap, admin); admin_cap.destroy_for_testing(); cleanup_trail_and_clock(trail, clock); @@ -419,14 +417,13 @@ fun test_update_locking_config_for_delete_record() { ts::ctx(&mut scenario), ); - let delete_lock_cap = trail - .roles_mut() - .new_capability_without_restrictions( - &admin_cap, - &string::utf8(b"DeleteLockAdmin"), - &clock, - ts::ctx(&mut scenario), - ); + let delete_lock_cap = test_utils::new_capability_without_restrictions( + trail.roles_mut(), + &admin_cap, + &string::utf8(b"DeleteLockAdmin"), + &clock, + ts::ctx(&mut scenario), + ); transfer::public_transfer(delete_lock_cap, admin); admin_cap.destroy_for_testing(); @@ -493,14 +490,13 @@ fun test_update_locking_config_for_delete_record_permission_denied() { ts::ctx(&mut scenario), ); - let wrong_cap = trail - .roles_mut() - .new_capability_without_restrictions( - &admin_cap, - &string::utf8(b"WrongPerm"), - &clock, - ts::ctx(&mut scenario), - ); + let wrong_cap = test_utils::new_capability_without_restrictions( + trail.roles_mut(), + &admin_cap, + &string::utf8(b"WrongPerm"), + &clock, + ts::ctx(&mut scenario), + ); transfer::public_transfer(wrong_cap, admin); admin_cap.destroy_for_testing(); @@ -556,14 +552,13 @@ fun test_delete_record_after_time_lock_expires() { ts::ctx(&mut scenario), ); - let record_cap = trail - .roles_mut() - .new_capability_without_restrictions( - &admin_cap, - &string::utf8(b"RecordAdmin"), - &clock, - ts::ctx(&mut scenario), - ); + let record_cap = test_utils::new_capability_without_restrictions( + trail.roles_mut(), + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); transfer::public_transfer(record_cap, admin); admin_cap.destroy_for_testing(); @@ -681,14 +676,13 @@ fun test_combined_time_and_count_locking_both_lock() { ts::ctx(&mut scenario), ); - let record_cap = trail - .roles_mut() - .new_capability_without_restrictions( - &admin_cap, - &string::utf8(b"RecordAdmin"), - &clock, - ts::ctx(&mut scenario), - ); + let record_cap = test_utils::new_capability_without_restrictions( + trail.roles_mut(), + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); // Add 5 records clock.set_for_testing(initial_time_for_testing() + 1000); @@ -766,14 +760,13 @@ fun test_combined_locking_time_expired_but_count_locked() { ts::ctx(&mut scenario), ); - let record_cap = trail - .roles_mut() - .new_capability_without_restrictions( - &admin_cap, - &string::utf8(b"RecordAdmin"), - &clock, - ts::ctx(&mut scenario), - ); + let record_cap = test_utils::new_capability_without_restrictions( + trail.roles_mut(), + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); // Add 5 records clock.set_for_testing(initial_time_for_testing() + 1000); @@ -852,14 +845,13 @@ fun test_combined_locking_count_satisfied_but_time_locked() { ts::ctx(&mut scenario), ); - let record_cap = trail - .roles_mut() - .new_capability_without_restrictions( - &admin_cap, - &string::utf8(b"RecordAdmin"), - &clock, - ts::ctx(&mut scenario), - ); + let record_cap = test_utils::new_capability_without_restrictions( + trail.roles_mut(), + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); // Add 5 records clock.set_for_testing(initial_time_for_testing() + 1000); @@ -935,14 +927,13 @@ fun test_combined_locking_both_satisfied_can_delete() { ts::ctx(&mut scenario), ); - let record_cap = trail - .roles_mut() - .new_capability_without_restrictions( - &admin_cap, - &string::utf8(b"RecordAdmin"), - &clock, - ts::ctx(&mut scenario), - ); + let record_cap = test_utils::new_capability_without_restrictions( + trail.roles_mut(), + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); // Add 5 records clock.set_for_testing(initial_time_for_testing() + 1000); diff --git a/audit-trail-move/tests/metadata_tests.move b/audit-trail-move/tests/metadata_tests.move index 1b619d47..bf091be7 100644 --- a/audit-trail-move/tests/metadata_tests.move +++ b/audit-trail-move/tests/metadata_tests.move @@ -3,10 +3,10 @@ module audit_trail::metadata_tests; use audit_trail::{ - capability::Capability, locking, permission, test_utils::{ + Self, setup_test_audit_trail, fetch_capability_trail_and_clock, cleanup_capability_trail_and_clock @@ -14,6 +14,7 @@ use audit_trail::{ }; use iota::test_scenario as ts; use std::string; +use tf_components::capability::Capability; // ===== Success Case Tests ===== @@ -53,14 +54,13 @@ fun test_update_metadata_success() { ); // Issue capability to metadata admin user - let metadata_cap = trail - .roles_mut() - .new_capability_without_restrictions( - &admin_cap, - &string::utf8(b"MetadataAdmin"), - &clock, - ts::ctx(&mut scenario), - ); + let metadata_cap = test_utils::new_capability_without_restrictions( + trail.roles_mut(), + &admin_cap, + &string::utf8(b"MetadataAdmin"), + &clock, + ts::ctx(&mut scenario), + ); transfer::public_transfer(metadata_cap, metadata_admin_user); cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); @@ -171,14 +171,13 @@ fun test_update_metadata_permission_denied() { ts::ctx(&mut scenario), ); - let user_cap = trail - .roles_mut() - .new_capability_without_restrictions( - &admin_cap, - &string::utf8(b"NoMetadataPerm"), - &clock, - ts::ctx(&mut scenario), - ); + let user_cap = test_utils::new_capability_without_restrictions( + trail.roles_mut(), + &admin_cap, + &string::utf8(b"NoMetadataPerm"), + &clock, + ts::ctx(&mut scenario), + ); transfer::public_transfer(user_cap, user); cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); @@ -240,14 +239,13 @@ fun test_update_metadata_revoked_capability() { ); // Issue capability - let metadata_cap = trail - .roles_mut() - .new_capability_without_restrictions( - &admin_cap, - &string::utf8(b"MetadataAdmin"), - &clock, - ts::ctx(&mut scenario), - ); + let metadata_cap = test_utils::new_capability_without_restrictions( + trail.roles_mut(), + &admin_cap, + &string::utf8(b"MetadataAdmin"), + &clock, + ts::ctx(&mut scenario), + ); transfer::public_transfer(metadata_cap, metadata_admin_user); cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); diff --git a/audit-trail-move/tests/record_tests.move b/audit-trail-move/tests/record_tests.move index c89c2ef6..d131030f 100644 --- a/audit-trail-move/tests/record_tests.move +++ b/audit-trail-move/tests/record_tests.move @@ -7,6 +7,7 @@ use audit_trail::{ main::{Self, AuditTrail}, permission, test_utils::{ + Self, TestData, setup_test_audit_trail, new_test_data, @@ -54,14 +55,13 @@ fun test_add_record_to_empty_trail() { ts::ctx(&mut scenario), ); - let record_cap = trail - .roles_mut() - .new_capability_without_restrictions( - &admin_cap, - &string::utf8(b"RecordAdmin"), - &clock, - ts::ctx(&mut scenario), - ); + let record_cap = test_utils::new_capability_without_restrictions( + trail.roles_mut(), + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); transfer::public_transfer(record_cap, admin); admin_cap.destroy_for_testing(); @@ -129,14 +129,13 @@ fun test_add_multiple_records() { ts::ctx(&mut scenario), ); - let record_cap = trail - .roles_mut() - .new_capability_without_restrictions( - &admin_cap, - &string::utf8(b"RecordAdmin"), - &clock, - ts::ctx(&mut scenario), - ); + let record_cap = test_utils::new_capability_without_restrictions( + trail.roles_mut(), + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); transfer::public_transfer(record_cap, admin); admin_cap.destroy_for_testing(); @@ -208,14 +207,13 @@ fun test_add_record_permission_denied() { ts::ctx(&mut scenario), ); - let no_add_cap = trail - .roles_mut() - .new_capability_without_restrictions( - &admin_cap, - &string::utf8(b"NoAddPerm"), - &clock, - ts::ctx(&mut scenario), - ); + let no_add_cap = test_utils::new_capability_without_restrictions( + trail.roles_mut(), + &admin_cap, + &string::utf8(b"NoAddPerm"), + &clock, + ts::ctx(&mut scenario), + ); transfer::public_transfer(no_add_cap, admin); admin_cap.destroy_for_testing(); @@ -276,14 +274,13 @@ fun test_delete_record_success() { ts::ctx(&mut scenario), ); - let record_cap = trail - .roles_mut() - .new_capability_without_restrictions( - &admin_cap, - &string::utf8(b"RecordAdmin"), - &clock, - ts::ctx(&mut scenario), - ); + let record_cap = test_utils::new_capability_without_restrictions( + trail.roles_mut(), + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); transfer::public_transfer(record_cap, admin); admin_cap.destroy_for_testing(); @@ -347,14 +344,13 @@ fun test_delete_record_permission_denied() { ts::ctx(&mut scenario), ); - let no_delete_cap = trail - .roles_mut() - .new_capability_without_restrictions( - &admin_cap, - &string::utf8(b"NoDeletePerm"), - &clock, - ts::ctx(&mut scenario), - ); + let no_delete_cap = test_utils::new_capability_without_restrictions( + trail.roles_mut(), + &admin_cap, + &string::utf8(b"NoDeletePerm"), + &clock, + ts::ctx(&mut scenario), + ); transfer::public_transfer(no_delete_cap, admin); admin_cap.destroy_for_testing(); @@ -408,14 +404,13 @@ fun test_delete_record_not_found() { ts::ctx(&mut scenario), ); - let record_cap = trail - .roles_mut() - .new_capability_without_restrictions( - &admin_cap, - &string::utf8(b"RecordAdmin"), - &clock, - ts::ctx(&mut scenario), - ); + let record_cap = test_utils::new_capability_without_restrictions( + trail.roles_mut(), + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); transfer::public_transfer(record_cap, admin); admin_cap.destroy_for_testing(); @@ -469,14 +464,13 @@ fun test_delete_record_time_locked() { ts::ctx(&mut scenario), ); - let record_cap = trail - .roles_mut() - .new_capability_without_restrictions( - &admin_cap, - &string::utf8(b"RecordAdmin"), - &clock, - ts::ctx(&mut scenario), - ); + let record_cap = test_utils::new_capability_without_restrictions( + trail.roles_mut(), + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); transfer::public_transfer(record_cap, admin); admin_cap.destroy_for_testing(); @@ -531,14 +525,13 @@ fun test_delete_record_count_locked() { ts::ctx(&mut scenario), ); - let record_cap = trail - .roles_mut() - .new_capability_without_restrictions( - &admin_cap, - &string::utf8(b"RecordAdmin"), - &clock, - ts::ctx(&mut scenario), - ); + let record_cap = test_utils::new_capability_without_restrictions( + trail.roles_mut(), + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); transfer::public_transfer(record_cap, admin); admin_cap.destroy_for_testing(); @@ -660,14 +653,13 @@ fun test_first_last_sequence() { ts::ctx(&mut scenario), ); - let record_cap = trail - .roles_mut() - .new_capability_without_restrictions( - &admin_cap, - &string::utf8(b"RecordAdmin"), - &clock, - ts::ctx(&mut scenario), - ); + let record_cap = test_utils::new_capability_without_restrictions( + trail.roles_mut(), + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); clock.set_for_testing(initial_time_for_testing() + 1000); diff --git a/audit-trail-move/tests/role_tests.move b/audit-trail-move/tests/role_tests.move index e4c32634..1501ac7b 100644 --- a/audit-trail-move/tests/role_tests.move +++ b/audit-trail-move/tests/role_tests.move @@ -38,7 +38,7 @@ fun test_role_based_permission_delegation() { // Verify admin capability was created with correct role and trail reference assert!(admin_cap.role() == initial_admin_role_name(), 0); - assert!(admin_cap.security_vault_id() == trail_id, 1); + assert!(admin_cap.target_key() == trail_id, 1); // Transfer the admin capability to the user transfer::public_transfer(admin_cap, admin_user); @@ -91,33 +91,31 @@ fun test_role_based_permission_delegation() { { let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); - let role_admin_cap = trail - .roles_mut() - .new_capability_without_restrictions( - &admin_cap, - &string::utf8(b"RoleAdmin"), - &clock, - ts::ctx(&mut scenario), - ); + let role_admin_cap = test_utils::new_capability_without_restrictions( + trail.roles_mut(), + &admin_cap, + &string::utf8(b"RoleAdmin"), + &clock, + ts::ctx(&mut scenario), + ); // Verify the capability was created with correct role and trail ID assert!(role_admin_cap.role() == string::utf8(b"RoleAdmin"), 6); - assert!(role_admin_cap.security_vault_id() == trail_id, 7); + assert!(role_admin_cap.target_key() == trail_id, 7); iota::transfer::public_transfer(role_admin_cap, role_admin_user); - let cap_admin_cap = trail - .roles_mut() - .new_capability_without_restrictions( - &admin_cap, - &string::utf8(b"CapAdmin"), - &clock, - ts::ctx(&mut scenario), - ); + let cap_admin_cap = test_utils::new_capability_without_restrictions( + trail.roles_mut(), + &admin_cap, + &string::utf8(b"CapAdmin"), + &clock, + ts::ctx(&mut scenario), + ); // Verify the capability was created with correct role and trail ID assert!(cap_admin_cap.role() == string::utf8(b"CapAdmin"), 8); - assert!(cap_admin_cap.security_vault_id() == trail_id, 9); + assert!(cap_admin_cap.target_key() == trail_id, 9); iota::transfer::public_transfer(cap_admin_cap, cap_admin_user); @@ -158,18 +156,17 @@ fun test_role_based_permission_delegation() { // Verify CapAdmin has the correct role assert!(cap_admin_cap.role() == string::utf8(b"CapAdmin"), 13); - let record_admin_cap = trail - .roles_mut() - .new_capability_without_restrictions( - &cap_admin_cap, - &string::utf8(b"RecordAdmin"), - &clock, - ts::ctx(&mut scenario), - ); + let record_admin_cap = test_utils::new_capability_without_restrictions( + trail.roles_mut(), + &cap_admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); // Verify the capability was created with correct role and trail ID assert!(record_admin_cap.role() == string::utf8(b"RecordAdmin"), 14); - assert!(record_admin_cap.security_vault_id() == trail_id, 15); + assert!(record_admin_cap.target_key() == trail_id, 15); iota::transfer::public_transfer(record_admin_cap, record_admin_user); @@ -308,14 +305,13 @@ fun test_create_role_permission_denied() { ts::ctx(&mut scenario), ); - let user_cap = trail - .roles_mut() - .new_capability_without_restrictions( - &admin_cap, - &string::utf8(b"NoRolesPerm"), - &clock, - ts::ctx(&mut scenario), - ); + let user_cap = test_utils::new_capability_without_restrictions( + trail.roles_mut(), + &admin_cap, + &string::utf8(b"NoRolesPerm"), + &clock, + ts::ctx(&mut scenario), + ); transfer::public_transfer(user_cap, user); cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); @@ -393,14 +389,13 @@ fun test_delete_role_permission_denied() { ts::ctx(&mut scenario), ); - let user_cap = trail - .roles_mut() - .new_capability_without_restrictions( - &admin_cap, - &string::utf8(b"NoDeleteRolePerm"), - &clock, - ts::ctx(&mut scenario), - ); + let user_cap = test_utils::new_capability_without_restrictions( + trail.roles_mut(), + &admin_cap, + &string::utf8(b"NoDeleteRolePerm"), + &clock, + ts::ctx(&mut scenario), + ); transfer::public_transfer(user_cap, user); cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); @@ -470,14 +465,13 @@ fun test_update_role_permissions_permission_denied() { ts::ctx(&mut scenario), ); - let user_cap = trail - .roles_mut() - .new_capability_without_restrictions( - &admin_cap, - &string::utf8(b"NoUpdateRolePerm"), - &clock, - ts::ctx(&mut scenario), - ); + let user_cap = test_utils::new_capability_without_restrictions( + trail.roles_mut(), + &admin_cap, + &string::utf8(b"NoUpdateRolePerm"), + &clock, + ts::ctx(&mut scenario), + ); transfer::public_transfer(user_cap, user); cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); diff --git a/audit-trail-move/tests/test_utils.move b/audit-trail-move/tests/test_utils.move index e0f5739e..f7d90649 100644 --- a/audit-trail-move/tests/test_utils.move +++ b/audit-trail-move/tests/test_utils.move @@ -1,11 +1,12 @@ #[test_only] module audit_trail::test_utils; -use audit_trail::{capability::Capability, locking, main::{Self, AuditTrail}}; +use audit_trail::{locking, main::{Self, AuditTrail}}; use iota::{clock::{Self, Clock}, test_scenario::{Self as ts, Scenario}}; use std::string; +use tf_components::{capability::Capability, role_map::RoleMap}; -const INITIAL_TIME_FOR_TESTING: u64 = 1234; +const INITIAL_TIME_FOR_TESTING: u64 = 1234567; /// Test data type for audit trail records public struct TestData has copy, drop, store { @@ -64,6 +65,92 @@ public(package) fun setup_test_audit_trail( (admin_cap, trail_id) } +/// Create a new unrestricted capability with a specific role without any +/// address, valid_from, or valid_until restrictions. +/// +/// Returns the newly created capability. +/// +/// Sends a CapabilityIssued event upon successful creation. +/// +/// Errors: +/// - Aborts with EPermissionDenied if the provided capability does not have the permission specified with `CapabilityAdminPermissions::add`. +/// - Aborts with ERoleDoesNotExist if the specified role does not exist in the role_map. +public fun new_capability_without_restrictions( + role_map: &mut RoleMap

, + cap: &Capability, + role: &string::String, + clock: &Clock, + ctx: &mut TxContext, +): Capability { + role_map.new_capability( + cap, + role, + std::option::none(), + std::option::none(), + std::option::none(), + clock, + ctx, + ) +} + +/// Create a new capability with a specific role that expires at a given timestamp (milliseconds since Unix epoch). +/// +/// Returns the newly created capability. +/// +/// Sends a CapabilityIssued event upon successful creation. +/// +/// Errors: +/// - Aborts with EPermissionDenied if the provided capability does not have the permission specified with `CapabilityAdminPermissions::add`. +/// - Aborts with ERoleDoesNotExist if the specified role does not exist in the role_map. +public(package) fun new_capability_valid_until( + role_map: &mut RoleMap

, + cap: &Capability, + role: &string::String, + valid_until: u64, + clock: &Clock, + ctx: &mut TxContext, +): Capability { + role_map.new_capability( + cap, + role, + std::option::none(), + std::option::none(), + std::option::some(valid_until), + clock, + ctx, + ) +} + +/// Create a new capability with a specific role restricted to an address. +/// Optionally set an expiration time (milliseconds since Unix epoch). +/// +/// Returns the newly created capability. +/// +/// Sends a CapabilityIssued event upon successful creation. +/// +/// Errors: +/// - Aborts with EPermissionDenied if the provided capability does not have the permission specified with `CapabilityAdminPermissions::add`. +/// - Aborts with ERoleDoesNotExist if the specified role does not exist in the role_map. +public fun new_capability_for_address( + role_map: &mut RoleMap

, + cap: &Capability, + role: &string::String, + issued_to: address, + valid_until: Option, + clock: &Clock, + ctx: &mut TxContext, +): Capability { + role_map.new_capability( + cap, + role, + std::option::some(issued_to), + std::option::none(), + valid_until, + clock, + ctx, + ) +} + public(package) fun fetch_capability_trail_and_clock( scenario: &mut Scenario, ): (Capability, AuditTrail, Clock) { diff --git a/bindings/wasm/notarization_wasm/src/wasm_time_lock.rs b/bindings/wasm/notarization_wasm/src/wasm_time_lock.rs index 9455bd90..cf3e42fd 100644 --- a/bindings/wasm/notarization_wasm/src/wasm_time_lock.rs +++ b/bindings/wasm/notarization_wasm/src/wasm_time_lock.rs @@ -9,14 +9,18 @@ use wasm_bindgen::prelude::*; /// /// This enum defines the possible types of time locks that can be applied to a notarization object. /// - `None`: No time lock is applied. -/// - `UnlockAt`: The object will unlock at a specific timestamp. +/// - `UnlockAt`: The object will unlock at a specific timestamp (seconds since Unix epoch). +/// - `UnlockAtMs`: Same as UnlockAt (unlocks at specific timestamp) but using milliseconds since Unix epoch. /// - `UntilDestroyed`: The object remains locked until it is destroyed. Can not be used for `delete_lock`. +/// - `Infinite`: The object is permanently locked and will never unlock. #[wasm_bindgen(js_name = TimeLockType)] #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum WasmTimeLockType { None = "None", UnlockAt = "UnlockAt", + UnlockAtMs = "UnlockAtMs", UntilDestroyed = "UntilDestroyed", + Infinite = "Infinite", } /// Represents a time lock configuration. @@ -28,16 +32,28 @@ pub struct WasmTimeLock(pub(crate) TimeLock); #[wasm_bindgen(js_class = TimeLock)] impl WasmTimeLock { - /// Creates a time lock that unlocks at a specific timestamp. + /// Creates a time lock that unlocks at a specific seconds based timestamp. /// /// # Arguments - /// * `time` - The timestamp in seconds since the Unix epoch at which the object will unlock. + /// * `time_sec` - The timestamp in seconds since the Unix epoch at which the object will unlock. /// /// # Returns /// A new `TimeLock` instance configured to unlock at the specified timestamp. #[wasm_bindgen(js_name = withUnlockAt)] - pub fn with_unlock_at(time: u32) -> Self { - Self(TimeLock::UnlockAt(time)) + pub fn with_unlock_at(time_sec: u32) -> Self { + Self(TimeLock::UnlockAt(time_sec)) + } + + /// Creates a time lock that unlocks at a specific milliseconds based timestamp. + /// + /// # Arguments + /// * `time_ms` - The timestamp in milliseconds since the Unix epoch at which the object will unlock. + /// + /// # Returns + /// A new `TimeLock` instance configured to unlock at the specified timestamp. + #[wasm_bindgen(js_name = withUnlockAtMs)] + pub fn with_unlock_at_ms(time_ms: u64) -> Self { + Self(TimeLock::UnlockAtMs(time_ms)) } /// Creates a time lock that remains locked until the object is destroyed. @@ -49,6 +65,15 @@ impl WasmTimeLock { Self(TimeLock::UntilDestroyed) } + /// Creates a time lock that is locked permanently and will never be unlocked + /// + /// # Returns + /// A new `TimeLock` instance configured to remain locked infinitely. + #[wasm_bindgen(js_name = withInfinite)] + pub fn with_infinite() -> Self { + Self(TimeLock::Infinite) + } + /// Creates a time lock with no restrictions. /// /// # Returns @@ -66,7 +91,9 @@ impl WasmTimeLock { pub fn lock_type(&self) -> WasmTimeLockType { match &self.0 { TimeLock::UnlockAt(_) => WasmTimeLockType::UnlockAt, + TimeLock::UnlockAtMs(_) => WasmTimeLockType::UnlockAtMs, TimeLock::UntilDestroyed => WasmTimeLockType::UntilDestroyed, + TimeLock::Infinite => WasmTimeLockType::Infinite, TimeLock::None => WasmTimeLockType::None, } } @@ -81,6 +108,7 @@ impl WasmTimeLock { pub fn args(&self) -> JsValue { match &self.0 { TimeLock::UnlockAt(u) => JsValue::from(*u), + TimeLock::UnlockAtMs(u) => JsValue::from(*u), _ => JsValue::UNDEFINED, } } diff --git a/notarization-move/Move.lock b/notarization-move/Move.lock index 920d6422..ad00e684 100644 --- a/notarization-move/Move.lock +++ b/notarization-move/Move.lock @@ -2,27 +2,59 @@ [move] version = 3 -manifest_digest = "2FA64AE578ECFADFE6576D98160FEBC1DFF70895E34236A183F6833371359743" -deps_digest = "3C4103934B1E040BB6B23F1D610B4EF9F2F1166A50A104EADCF77467C004C600" +manifest_digest = "8019AAD757782B3104C1350C12F73AA444CDAA9B3E4B40A2468C3DA235715C42" +deps_digest = "397E6A9F7A624706DBDFEE056CE88391A15876868FD18A88504DA74EB458D697" dependencies = [ { id = "Iota", name = "Iota" }, + { id = "IotaSystem", name = "IotaSystem" }, { id = "MoveStdlib", name = "MoveStdlib" }, + { id = "Stardust", name = "Stardust" }, + { id = "tf_components", name = "tf_components" }, ] [[move.package]] id = "Iota" -source = { git = "https://github.com/iotaledger/iota.git", rev = "d9dbd00f5601f20f9d0d8381fc674f70869c7910", subdir = "crates/iota-framework/packages/iota-framework" } +source = { git = "https://github.com/iotaledger/iota.git", rev = "4698c6723208e052a00c74602d2c8dc0efffe5de", subdir = "crates/iota-framework/packages/iota-framework" } dependencies = [ { id = "MoveStdlib", name = "MoveStdlib" }, ] +[[move.package]] +id = "IotaSystem" +source = { git = "https://github.com/iotaledger/iota.git", rev = "4698c6723208e052a00c74602d2c8dc0efffe5de", subdir = "crates/iota-framework/packages/iota-system" } + +dependencies = [ + { id = "Iota", name = "Iota" }, + { id = "MoveStdlib", name = "MoveStdlib" }, +] + [[move.package]] id = "MoveStdlib" -source = { git = "https://github.com/iotaledger/iota.git", rev = "d9dbd00f5601f20f9d0d8381fc674f70869c7910", subdir = "crates/iota-framework/packages/move-stdlib" } +source = { git = "https://github.com/iotaledger/iota.git", rev = "4698c6723208e052a00c74602d2c8dc0efffe5de", subdir = "crates/iota-framework/packages/move-stdlib" } + +[[move.package]] +id = "Stardust" +source = { git = "https://github.com/iotaledger/iota.git", rev = "4698c6723208e052a00c74602d2c8dc0efffe5de", subdir = "crates/iota-framework/packages/stardust" } + +dependencies = [ + { id = "Iota", name = "Iota" }, + { id = "MoveStdlib", name = "MoveStdlib" }, +] + +[[move.package]] +id = "tf_components" +source = { git = "https://github.com/iotaledger/product-core.git", rev = "feat/role-map", subdir = "components_move" } + +dependencies = [ + { id = "Iota", name = "Iota" }, + { id = "IotaSystem", name = "IotaSystem" }, + { id = "MoveStdlib", name = "MoveStdlib" }, + { id = "Stardust", name = "Stardust" }, +] [move.toolchain-version] -compiler-version = "1.2.3" +compiler-version = "1.14.1" edition = "2024.beta" flavor = "iota" diff --git a/notarization-move/Move.toml b/notarization-move/Move.toml index 93671c18..4726d096 100644 --- a/notarization-move/Move.toml +++ b/notarization-move/Move.toml @@ -6,6 +6,7 @@ name = "IotaNotarization" edition = "2024.beta" [dependencies] +TfComponents = { git = "https://github.com/iotaledger/product-core.git", subdir = "components_move", rev = "main" } [addresses] iota_notarization = "0x0" diff --git a/notarization-move/sources/dynamic_notarization.move b/notarization-move/sources/dynamic_notarization.move index ca5e7950..b9edcfae 100644 --- a/notarization-move/sources/dynamic_notarization.move +++ b/notarization-move/sources/dynamic_notarization.move @@ -5,8 +5,9 @@ module iota_notarization::dynamic_notarization; use iota::{clock::Clock, event}; -use iota_notarization::{notarization, timelock::TimeLock}; +use iota_notarization::notarization; use std::string::String; +use tf_components::timelock::TimeLock; // ===== Constants ===== /// Cannot transfer a locked notarization diff --git a/notarization-move/sources/locked_notarization.move b/notarization-move/sources/locked_notarization.move index f1c02fa0..e843e9c9 100644 --- a/notarization-move/sources/locked_notarization.move +++ b/notarization-move/sources/locked_notarization.move @@ -5,8 +5,9 @@ module iota_notarization::locked_notarization; use iota::{clock::Clock, event}; -use iota_notarization::{notarization, timelock::TimeLock}; +use iota_notarization::notarization; use std::string::String; +use tf_components::timelock::TimeLock; /// Event emitted when a locked notarization is created public struct LockedNotarizationCreated has copy, drop { diff --git a/notarization-move/sources/notarization.move b/notarization-move/sources/notarization.move index 043eeca2..757ddefb 100644 --- a/notarization-move/sources/notarization.move +++ b/notarization-move/sources/notarization.move @@ -7,11 +7,9 @@ module iota_notarization::notarization; use iota::{clock::{Self, Clock}, event}; -use iota_notarization::{ - method::{NotarizationMethod, new_dynamic, new_locked}, - timelock::{Self, TimeLock} -}; +use iota_notarization::method::{NotarizationMethod, new_dynamic, new_locked}; use std::string::String; +use tf_components::timelock::{Self, TimeLock}; // ===== Constants ===== /// Cannot update state while notarization is locked for updates diff --git a/notarization-move/sources/timelock.move b/notarization-move/sources/timelock.move deleted file mode 100644 index c6eb46f5..00000000 --- a/notarization-move/sources/timelock.move +++ /dev/null @@ -1,127 +0,0 @@ -// Copyright (c) 2025 IOTA Stiftung -// SPDX-License-Identifier: Apache-2.0 - -/// # Timelock Unlock Condition Module -/// -/// This module implements a timelock mechanism that restricts access to resources -/// until a specified time has passed. It provides functionality to create and validate -/// different types of time-based locks: -/// -/// - Simple time locks that unlock at a specific Unix timestamp -/// - UntilDestroyed lock that never unlocks until the notarization is destroyed -/// - None lock that is not locked -module iota_notarization::timelock; - -use iota::clock::{Self, Clock}; - -// ===== Errors ===== -/// Error when attempting to create a timelock with a timestamp in the past -const EPastTimestamp: u64 = 0; -/// Error when attempting to destroy a timelock that is still locked -const ETimelockNotExpired: u64 = 1; - -/// Represents different types of time-based locks that can be applied to -/// notarizations. -public enum TimeLock has store { - /// A lock that unlocks at a specific Unix timestamp (seconds since Unix epoch) - UnlockAt(u32), - /// A permanent lock that never unlocks until the notarization object is destroyed (can't be used for `delete_lock`) - UntilDestroyed, - /// No lock applied - None, -} - -/// Creates a new time lock that unlocks at a specific Unix timestamp. -public fun unlock_at(unix_time: u32, clock: &Clock): TimeLock { - let now = (clock::timestamp_ms(clock) / 1000) as u32; - - assert!(is_valid_period(unix_time, now), EPastTimestamp); - - TimeLock::UnlockAt(unix_time) -} - -/// Creates a new UntilDestroyed lock that never unlocks until the notarization object is destroyed. -public fun until_destroyed(): TimeLock { - TimeLock::UntilDestroyed -} - -/// Create a new lock that is not locked. -public fun none(): TimeLock { - TimeLock::None -} - -/// Checks if the provided lock time is an UntilDestroyed lock. -public fun is_until_destroyed(lock_time: &TimeLock): bool { - match (lock_time) { - TimeLock::UntilDestroyed => true, - _ => false, - } -} - -/// Checks if the provided lock time is a UnlockAt lock. -public fun is_unlock_at(lock_time: &TimeLock): bool { - match (lock_time) { - TimeLock::UnlockAt(_) => true, - _ => false, - } -} - -/// Checks if the provided lock time is a None lock. -public fun is_none(lock_time: &TimeLock): bool { - match (lock_time) { - TimeLock::None => true, - _ => false, - } -} - -/// Gets the unlock time from a TimeLock if it is a UnixTime lock. -public fun get_unlock_time(lock_time: &TimeLock): Option { - match (lock_time) { - TimeLock::UnlockAt(time) => option::some(*time), - _ => option::none(), - } -} - -/// Destroys a TimeLock if it's either unlocked or an UntilDestroyed lock. -public fun destroy(condition: TimeLock, clock: &Clock) { - // The TimeLock is always destroyed, except of those cases where an assertion is raised - match (condition) { - TimeLock::UnlockAt(time) => { - assert!(!(time > ((clock::timestamp_ms(clock) / 1000) as u32)), ETimelockNotExpired); - }, - TimeLock::UntilDestroyed => {}, - TimeLock::None => {}, - } -} - -/// Checks if a timelock condition is currently active (locked). -/// -/// This function evaluates whether a given TimeLock instance is currently in a locked state -/// by comparing the current time with the lock's parameters. A lock is considered active if: -/// 1. For UnixTime locks: The current time hasn't reached the specified unlock time yet -/// 2. For UntilDestroyed: Always returns true as these locks never unlock until the notarization is destroyed -/// 3. For None: Always returns false as there is no lock -public fun is_timelocked(condition: &TimeLock, clock: &Clock): bool { - match (condition) { - TimeLock::UnlockAt(unix_time) => { - *unix_time > ((clock::timestamp_ms(clock) / 1000) as u32) - }, - TimeLock::UntilDestroyed => true, - TimeLock::None => false, - } -} - -/// Check if a timelock condition is `UnlockAt` -public fun is_timelocked_unlock_at(lock_time: &TimeLock, clock: &Clock): bool { - match (lock_time) { - TimeLock::UnlockAt(time) => { - *time > ((clock::timestamp_ms(clock) / 1000) as u32) - }, - _ => false, - } -} - -/// Validates that a specified unlock time is in the future. -public fun is_valid_period(unix_time: u32, current_time: u32): bool { - unix_time > current_time -} diff --git a/notarization-move/tests/dynamic_notarization_tests.move b/notarization-move/tests/dynamic_notarization_tests.move index 6c929873..0077bbde 100644 --- a/notarization-move/tests/dynamic_notarization_tests.move +++ b/notarization-move/tests/dynamic_notarization_tests.move @@ -6,8 +6,9 @@ module iota_notarization::dynamic_notarization_tests; use iota::{clock, test_scenario::{Self as ts, ctx}}; -use iota_notarization::{dynamic_notarization, notarization, timelock}; +use iota_notarization::{dynamic_notarization, notarization}; use std::string; +use tf_components::timelock; const ADMIN_ADDRESS: address = @0x01; const RECIPIENT_ADDRESS: address = @0x02; diff --git a/notarization-move/tests/locked_notarization_tests.move b/notarization-move/tests/locked_notarization_tests.move index 43a05780..8f04965d 100644 --- a/notarization-move/tests/locked_notarization_tests.move +++ b/notarization-move/tests/locked_notarization_tests.move @@ -6,8 +6,9 @@ module iota_notarization::locked_notarization_tests; use iota::{clock, test_scenario as ts}; -use iota_notarization::{locked_notarization, notarization, timelock}; +use iota_notarization::{locked_notarization, notarization}; use std::string; +use tf_components::timelock; const ADMIN_ADDRESS: address = @0x1; diff --git a/notarization-move/tests/notarization_tests.move b/notarization-move/tests/notarization_tests.move index 17e37167..dc317470 100644 --- a/notarization-move/tests/notarization_tests.move +++ b/notarization-move/tests/notarization_tests.move @@ -6,8 +6,9 @@ module iota_notarization::notarization_tests; use iota::{clock, test_scenario as ts}; -use iota_notarization::{notarization, timelock}; +use iota_notarization::notarization; use std::string; +use tf_components::timelock; const ADMIN_ADDRESS: address = @0x1; diff --git a/notarization-move/tests/timelock_tests.move b/notarization-move/tests/timelock_tests.move deleted file mode 100644 index 68aa0f13..00000000 --- a/notarization-move/tests/timelock_tests.move +++ /dev/null @@ -1,202 +0,0 @@ -// Copyright (c) 2024 IOTA Stiftung -// SPDX-License-Identifier: Apache-2.0 - -/// This module provides tests for the timelock module -#[test_only] -module iota_notarization::timelock_tests; - -use iota::{clock, test_scenario::{Self as ts, ctx}}; -use iota_notarization::timelock; - -const ADMIN_ADDRESS: address = @0x01; - -#[test] -public fun test_new_unlock_at() { - let mut ts = ts::begin(ADMIN_ADDRESS); - - let ctx = ts.ctx(); - - let mut clock = clock::create_for_testing(ctx); - clock::set_for_testing(&mut clock, 1000000); - - let lock = timelock::unlock_at(1001, &clock); - - assert!(timelock::is_unlock_at(&lock)); - assert!(timelock::get_unlock_time(&lock) == std::option::some(1001)); - assert!(timelock::is_timelocked(&lock, &clock)); - - // Advance time by setting a new timestamp - clock::increment_for_testing(&mut clock, 1000); - - assert!(!timelock::is_timelocked(&lock, &clock)); - - timelock::destroy(lock, &clock); - clock::destroy_for_testing(clock); - - ts.end(); -} - -#[test] -#[expected_failure(abort_code = timelock::EPastTimestamp)] -public fun test_new_unlock_at_past_time() { - let mut ts = ts::begin(ADMIN_ADDRESS); - let ctx = ts.ctx(); - - let mut clock = clock::create_for_testing(ctx); - clock::set_for_testing(&mut clock, 1000000); - - // Try to create a timelock with a timestamp in the past - let lock = timelock::unlock_at(999, &clock); - - // This should never be reached - timelock::destroy(lock, &clock); - clock::destroy_for_testing(clock); - - ts.end(); -} - -#[test] -public fun test_until_destroyed() { - let mut ts = ts::begin(ADMIN_ADDRESS); - let ctx = ts.ctx(); - - let mut clock = clock::create_for_testing(ctx); - clock::set_for_testing(&mut clock, 1000000); - - let lock = timelock::until_destroyed(); - - assert!(timelock::is_until_destroyed(&lock)); - assert!(!timelock::is_unlock_at(&lock)); - assert!(timelock::get_unlock_time(&lock) == std::option::none()); - - // UntilDestroyed is always timelocked - assert!(timelock::is_timelocked(&lock, &clock)); - - // Even after a long time - clock::increment_for_testing(&mut clock, 1000000); - assert!(timelock::is_timelocked(&lock, &clock)); - - // UntilDestroyed can always be destroyed without error - timelock::destroy(lock, &clock); - clock::destroy_for_testing(clock); - - ts.end(); -} - -#[test] -public fun test_none_lock() { - let mut ts = ts::begin(ADMIN_ADDRESS); - let ctx = ts.ctx(); - - let mut clock = clock::create_for_testing(ctx); - clock::set_for_testing(&mut clock, 1000000); - - let lock = timelock::none(); - - assert!(!timelock::is_until_destroyed(&lock)); - assert!(!timelock::is_unlock_at(&lock)); - assert!(timelock::get_unlock_time(&lock) == std::option::none()); - - // None is never timelocked - assert!(!timelock::is_timelocked(&lock, &clock)); - - // None can always be destroyed without error - timelock::destroy(lock, &clock); - clock::destroy_for_testing(clock); - - ts.end(); -} - -#[test] -#[expected_failure(abort_code = timelock::ETimelockNotExpired)] -public fun test_destroy_locked_timelock() { - let mut ts = ts::begin(ADMIN_ADDRESS); - let ctx = ts.ctx(); - - let mut clock = clock::create_for_testing(ctx); - clock::set_for_testing(&mut clock, 1000000); - - // Create a timelock that unlocks at time 2000 - let lock = timelock::unlock_at(2000, &clock); - - // Try to destroy it before it's unlocked - // This should fail with ETimelockNotExpired - timelock::destroy(lock, &clock); - - // These should never be reached - clock::destroy_for_testing(clock); - ts.end(); -} - -#[test] -public fun test_is_timelocked_unlock_at() { - let mut ts = ts::begin(ADMIN_ADDRESS); - let ctx = ts.ctx(); - - let mut clock = clock::create_for_testing(ctx); - clock::set_for_testing(&mut clock, 1000000); - - // Create different types of locks - let unlock_at_lock = timelock::unlock_at(2000, &clock); - let until_destroyed_lock = timelock::until_destroyed(); - let none_lock = timelock::none(); - - // Test is_timelocked_unlock_at - assert!(timelock::is_timelocked_unlock_at(&unlock_at_lock, &clock)); - assert!(!timelock::is_timelocked_unlock_at(&until_destroyed_lock, &clock)); - assert!(!timelock::is_timelocked_unlock_at(&none_lock, &clock)); - - // Advance time past unlock time - clock::increment_for_testing(&mut clock, 1000000); - - // Now the unlock_at lock should not be timelocked - assert!(!timelock::is_timelocked_unlock_at(&unlock_at_lock, &clock)); - - // Clean up - timelock::destroy(unlock_at_lock, &clock); - timelock::destroy(until_destroyed_lock, &clock); - timelock::destroy(none_lock, &clock); - clock::destroy_for_testing(clock); - - ts.end(); -} - -#[test] -public fun test_is_valid_period() { - // Test valid periods - assert!(timelock::is_valid_period(1001, 1000)); - assert!(timelock::is_valid_period(2000, 1000)); - - // Test invalid periods - assert!(!timelock::is_valid_period(1000, 1000)); // Equal time - assert!(!timelock::is_valid_period(999, 1000)); // Past time -} - -#[test] -public fun test_edge_cases() { - let mut ts = ts::begin(ADMIN_ADDRESS); - let ctx = ts.ctx(); - - let mut clock = clock::create_for_testing(ctx); - clock::set_for_testing(&mut clock, 1000000); - - // Test with time just one second in the future - let one_second_future = timelock::unlock_at(1001, &clock); - assert!(timelock::is_timelocked(&one_second_future, &clock)); - clock::set_for_testing(&mut clock, 1001000); - assert!(!timelock::is_timelocked(&one_second_future, &clock)); - - // Test with time exactly at the current time boundary - clock::set_for_testing(&mut clock, 2000000); - let exact_current_time = timelock::unlock_at(2001, &clock); - assert!(timelock::is_timelocked(&exact_current_time, &clock)); - clock::set_for_testing(&mut clock, 2001000); - assert!(!timelock::is_timelocked(&exact_current_time, &clock)); - - // Clean up - timelock::destroy(one_second_future, &clock); - timelock::destroy(exact_current_time, &clock); - clock::destroy_for_testing(clock); - - ts.end(); -} diff --git a/notarization-rs/src/core/types/timelock.rs b/notarization-rs/src/core/types/timelock.rs index 07beba36..2c4ca477 100644 --- a/notarization-rs/src/core/types/timelock.rs +++ b/notarization-rs/src/core/types/timelock.rs @@ -8,12 +8,6 @@ //! ## Overview //! //! The time-based locks are used to restrict the access to a notarization. -//! -//! ## Types -//! -//! - `UnlockAt`: The lock is unlocked at a specific time. -//! - `UntilDestroyed`: The lock is locked until the notarization is destroyed. -//! - `None`: The lock is not applied. use std::str::FromStr; use std::time::SystemTime; @@ -40,20 +34,25 @@ pub struct LockMetadata { /// notarizations. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] pub enum TimeLock { - /// A lock that is unlocked at a specific time. + /// A lock that unlocks at a specific Unix timestamp (seconds since Unix epoch) UnlockAt(u32), - /// A lock that is unlocked when the notarization is destroyed. + /// Same as UnlockAt (unlocks at specific timestamp) but using milliseconds since Unix epoch + UnlockAtMs(u64), + /// A permanent lock that never unlocks until the locked object is destroyed (can't be used for `delete_lock`) UntilDestroyed, + /// A lock that never unlocks (permanent lock) + Infinite, + /// No lock applied None, } impl TimeLock { - /// Creates a new `TimeLock` with a specified unlock time.\ + /// Creates a new `TimeLock::UnlockAt` with a specified unlock time.\ /// /// The unlock time is the time in seconds since the Unix epoch and /// must be in the future. - pub fn new_with_ts(unlock_time: u32) -> Result { - if unlock_time + pub fn new_with_ts(unlock_time_sec: u32) -> Result { + if unlock_time_sec <= SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) .expect("system time is before the Unix epoch") @@ -62,7 +61,24 @@ impl TimeLock { return Err(Error::InvalidArgument("unlock time must be in the future".to_string())); } - Ok(TimeLock::UnlockAt(unlock_time)) + Ok(TimeLock::UnlockAt(unlock_time_sec)) + } + + /// Creates a new `TimeLock::UnlockAtMs` with a specified unlock time.\ + /// + /// The unlock time is the time in milliseconds since the Unix epoch and + /// must be in the future. + pub fn new_with_ts_ms(unlock_time_ms: u64) -> Result { + if unlock_time_ms + <= SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .expect("system time is before the Unix epoch") + .as_millis() as u64 + { + return Err(Error::InvalidArgument("unlock time must be in the future".to_string())); + } + + Ok(TimeLock::UnlockAtMs(unlock_time_ms)) } /// Creates a new `Argument` from the `TimeLock`. @@ -71,23 +87,39 @@ impl TimeLock { pub(in crate::core) fn to_ptb(&self, ptb: &mut Ptb, package_id: ObjectID) -> Result { match self { TimeLock::UnlockAt(unlock_time) => new_unlock_at(ptb, *unlock_time, package_id), + TimeLock::UnlockAtMs(unlock_time) => new_unlock_at_ms(ptb, *unlock_time, package_id), TimeLock::UntilDestroyed => new_until_destroyed(ptb, package_id), + TimeLock::Infinite => new_infinite(ptb, package_id), TimeLock::None => new_none(ptb, package_id), } } } /// Creates a new `Argument` for the `unlock_at` function. -pub(super) fn new_unlock_at(ptb: &mut Ptb, unlock_time: u32, package_id: ObjectID) -> Result { +pub(super) fn new_unlock_at(ptb: &mut Ptb, unlock_time_sec: u32, package_id: ObjectID) -> Result { let clock = move_utils::get_clock_ref(ptb); - let unlock_time = move_utils::ptb_pure(ptb, "unlock_time", unlock_time)?; + let unlock_time_sec = move_utils::ptb_pure(ptb, "unlock_time", unlock_time_sec)?; Ok(ptb.programmable_move_call( package_id, ident_str!("timelock").into(), ident_str!("unlock_at").into(), vec![], - vec![unlock_time, clock], + vec![unlock_time_sec, clock], + )) +} + +/// Creates a new `Argument` for the `unlock_at` function. +pub(super) fn new_unlock_at_ms(ptb: &mut Ptb, unlock_time_ms: u64, package_id: ObjectID) -> Result { + let clock = move_utils::get_clock_ref(ptb); + let unlock_time_ms = move_utils::ptb_pure(ptb, "unlock_time", unlock_time_ms)?; + + Ok(ptb.programmable_move_call( + package_id, + ident_str!("timelock").into(), + ident_str!("unlock_at").into(), + vec![], + vec![unlock_time_ms, clock], )) } @@ -102,6 +134,17 @@ pub(super) fn new_until_destroyed(ptb: &mut Ptb, package_id: ObjectID) -> Result )) } +/// Creates a new `Argument` for the `until_destroyed` function. +pub(super) fn new_infinite(ptb: &mut Ptb, package_id: ObjectID) -> Result { + Ok(ptb.programmable_move_call( + package_id, + ident_str!("timelock").into(), + ident_str!("infinite").into(), + vec![], + vec![], + )) +} + /// Creates a new `Argument` for the `none` function. pub(super) fn new_none(ptb: &mut Ptb, package_id: ObjectID) -> Result { Ok(ptb.programmable_move_call( From 4aea04e67cffe872695229a1c3c510a141acea16 Mon Sep 17 00:00:00 2001 From: Yasir Date: Fri, 6 Feb 2026 16:41:30 +0300 Subject: [PATCH 047/189] feat: Implement audit trail metadata update and migration operations - Added `update_metadata` function in `metadata.rs` to allow updating metadata for an audit trail. - Introduced `migrate` function in `migrate.rs` for migrating audit trails. - Created `mod.rs` to organize audit trail operations, including create, locking, metadata, migrate, and records. - Implemented record management functions in `records.rs` for adding, deleting, and retrieving records. - Developed `CreateTrail` and `AddRecord` transaction types to facilitate trail creation and record addition. - Enhanced `Data` type in `record.rs` to support both bytes and text, with serialization and deserialization logic. - Updated various core types and error handling to reflect new functionalities. - Added end-to-end tests for creating trails and managing records. --- audit-trail-move/scripts/publish_package.sh | 17 ++ audit-trail-move/sources/record.move | 20 +-- audit-trail-rs/src/client/full_client.rs | 115 ++++++++++-- audit-trail-rs/src/client/mod.rs | 6 +- audit-trail-rs/src/client/read_only.rs | 62 ++++++- audit-trail-rs/src/core/builder.rs | 72 ++++++++ .../src/core/handler/capabilities.rs | 40 +++++ audit-trail-rs/src/core/handler/handle.rs | 50 ++++++ audit-trail-rs/src/core/handler/locking.rs | 41 +++++ audit-trail-rs/src/core/handler/metadata.rs | 26 +++ audit-trail-rs/src/core/handler/mod.rs | 34 ++++ audit-trail-rs/src/core/handler/records.rs | 139 +++++++++++++++ audit-trail-rs/src/core/handler/roles.rs | 96 ++++++++++ audit-trail-rs/src/core/mod.rs | 7 +- audit-trail-rs/src/core/move_utils.rs | 121 +++++++++++++ audit-trail-rs/src/core/operations/create.rs | 56 ++++++ audit-trail-rs/src/core/operations/locking.rs | 54 ++++++ .../src/core/operations/metadata.rs | 30 ++++ audit-trail-rs/src/core/operations/migrate.rs | 28 +++ audit-trail-rs/src/core/operations/mod.rs | 136 ++++++++++++++ audit-trail-rs/src/core/operations/records.rs | 104 +++++++++++ .../src/core/transactions/create.rs | 121 +++++++++++++ audit-trail-rs/src/core/transactions/mod.rs | 8 + .../src/core/transactions/record.rs | 156 ++++++++++++++++ audit-trail-rs/src/core/types/audit_trail.rs | 4 +- audit-trail-rs/src/core/types/capability.rs | 12 +- audit-trail-rs/src/core/types/event.rs | 2 +- audit-trail-rs/src/core/types/locking.rs | 2 +- audit-trail-rs/src/core/types/metadata.rs | 2 +- audit-trail-rs/src/core/types/mod.rs | 2 +- audit-trail-rs/src/core/types/permission.rs | 2 +- audit-trail-rs/src/core/types/record.rs | 166 ++++++++++++++++-- .../src/core/types/record_correction.rs | 2 +- audit-trail-rs/src/core/types/role_map.rs | 2 +- audit-trail-rs/src/error.rs | 5 +- .../src/iota_interaction_adapter.rs | 2 +- audit-trail-rs/src/lib.rs | 2 +- audit-trail-rs/src/package.rs | 2 +- audit-trail-rs/tests/e2e/client.rs | 111 ++++++++++++ audit-trail-rs/tests/e2e/main.rs | 5 + audit-trail-rs/tests/e2e/records.rs | 51 ++++++ 41 files changed, 1850 insertions(+), 63 deletions(-) create mode 100644 audit-trail-move/scripts/publish_package.sh create mode 100644 audit-trail-rs/src/core/builder.rs create mode 100644 audit-trail-rs/src/core/handler/capabilities.rs create mode 100644 audit-trail-rs/src/core/handler/handle.rs create mode 100644 audit-trail-rs/src/core/handler/locking.rs create mode 100644 audit-trail-rs/src/core/handler/metadata.rs create mode 100644 audit-trail-rs/src/core/handler/mod.rs create mode 100644 audit-trail-rs/src/core/handler/records.rs create mode 100644 audit-trail-rs/src/core/handler/roles.rs create mode 100644 audit-trail-rs/src/core/move_utils.rs create mode 100644 audit-trail-rs/src/core/operations/create.rs create mode 100644 audit-trail-rs/src/core/operations/locking.rs create mode 100644 audit-trail-rs/src/core/operations/metadata.rs create mode 100644 audit-trail-rs/src/core/operations/migrate.rs create mode 100644 audit-trail-rs/src/core/operations/mod.rs create mode 100644 audit-trail-rs/src/core/operations/records.rs create mode 100644 audit-trail-rs/src/core/transactions/create.rs create mode 100644 audit-trail-rs/src/core/transactions/mod.rs create mode 100644 audit-trail-rs/src/core/transactions/record.rs create mode 100644 audit-trail-rs/tests/e2e/client.rs create mode 100644 audit-trail-rs/tests/e2e/main.rs create mode 100644 audit-trail-rs/tests/e2e/records.rs diff --git a/audit-trail-move/scripts/publish_package.sh b/audit-trail-move/scripts/publish_package.sh new file mode 100644 index 00000000..faebef37 --- /dev/null +++ b/audit-trail-move/scripts/publish_package.sh @@ -0,0 +1,17 @@ +#!/bin/bash + +# Copyright 2020-2026 IOTA Stiftung +# SPDX-License-Identifier: Apache-2.0 + +script_dir=$(cd "$(dirname $0)" && pwd) +package_dir=$script_dir/.. + +RESPONSE=$(iota client publish --with-unpublished-dependencies --silence-warnings --json --gas-budget 500000000 $package_dir) +{ # try + PACKAGE_ID=$(echo $RESPONSE | jq --raw-output '.objectChanges[] | select(.type | contains("published")) | .packageId') +} || { # catch + echo $RESPONSE +} + +export IOTA_AUDIT_TRAIL_PKG_ID=$PACKAGE_ID +echo "${IOTA_AUDIT_TRAIL_PKG_ID}" diff --git a/audit-trail-move/sources/record.move b/audit-trail-move/sources/record.move index 37649df5..670f98f9 100644 --- a/audit-trail-move/sources/record.move +++ b/audit-trail-move/sources/record.move @@ -13,9 +13,9 @@ use std::string::String; /// A single record in the audit trail public struct Record has store { /// Arbitrary data stored on-chain - stored_data: D, + data: D, /// Optional metadata for this specific record - record_metadata: Option, + metadata: Option, /// Position in the trail (0-indexed, never reused) sequence_number: u64, /// Who added this record @@ -30,16 +30,16 @@ public struct Record has store { /// Create a new record public(package) fun new( - stored_data: D, - record_metadata: Option, + data: D, + metadata: Option, sequence_number: u64, added_by: address, added_at: u64, correction: RecordCorrection, ): Record { Record { - stored_data, - record_metadata, + data, + metadata, sequence_number, added_by, added_at, @@ -51,12 +51,12 @@ public(package) fun new( /// Get the stored data from a record public fun data(record: &Record): &D { - &record.stored_data + &record.data } /// Get the record metadata public fun metadata(record: &Record): &Option { - &record.record_metadata + &record.metadata } /// Get the record sequence number @@ -82,8 +82,8 @@ public fun correction(record: &Record): &RecordCorrection { /// Destroy a record public(package) fun destroy(record: Record) { let Record { - stored_data: _, - record_metadata: _, + data: _, + metadata: _, sequence_number: _, added_by: _, added_at: _, diff --git a/audit-trail-rs/src/client/full_client.rs b/audit-trail-rs/src/client/full_client.rs index b1353c3a..73c5235f 100644 --- a/audit-trail-rs/src/client/full_client.rs +++ b/audit-trail-rs/src/client/full_client.rs @@ -1,36 +1,125 @@ -// Copyright 2020-2025 IOTA Stiftung +// Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -//! A minimal full client wrapper for audit trail interactions. +//! A full client wrapper for audit trail interactions. //! -//! This is a scaffold that will be extended with transaction-building capabilities -//! once the Move contract API is finalized. +//! This client includes signing capabilities for executing transactions. use std::ops::Deref; use crate::client::read_only::AuditTrailClientReadOnly; +use crate::core::builder::AuditTrailBuilder; +use crate::core::handler::{AuditTrailFull, AuditTrailHandle, AuditTrailReadOnly}; +use crate::error::Error; +use iota_interaction::types::base_types::ObjectID; +use iota_interaction::types::transaction::ProgrammableTransaction; +use iota_interaction::{IotaKeySignature, OptionalSync}; +use iota_sdk::types::crypto::PublicKey; +use product_common::core_client::{CoreClient, CoreClientReadOnly}; +use product_common::network_name::NetworkName; +use secret_storage::Signer; +use serde::de::DeserializeOwned; -/// A full client that wraps the read-only client and will host write operations. +/// A full client that wraps the read-only client and hosts write operations. #[derive(Clone)] -pub struct AuditTrailClient { +pub struct AuditTrailClient { read_client: AuditTrailClientReadOnly, + public_key: PublicKey, + signer: S, } -impl Deref for AuditTrailClient { +impl Deref for AuditTrailClient { type Target = AuditTrailClientReadOnly; fn deref(&self) -> &Self::Target { &self.read_client } } -impl AuditTrailClient { - /// Creates a new full client from an existing read-only client. - pub fn new(read_client: AuditTrailClientReadOnly) -> Self { - Self { read_client } +impl AuditTrailClient +where + S: Signer, +{ + pub async fn new(client: AuditTrailClientReadOnly, signer: S) -> Result { + let public_key = signer + .public_key() + .await + .map_err(|e| Error::InvalidKey(e.to_string()))?; + + Ok(Self { + public_key, + read_client: client, + signer, + }) } +} - /// Returns a reference to the underlying read-only client. - pub const fn read_only(&self) -> &AuditTrailClientReadOnly { +impl AuditTrailClient { + pub fn read_only(&self) -> &AuditTrailClientReadOnly { &self.read_client } + + pub fn trail<'a>(&'a self, trail_id: ObjectID) -> AuditTrailHandle<'a, Self> { + AuditTrailHandle::new(self, trail_id) + } + + /// Creates a builder for an audit trail. + pub fn create_trail(&self) -> AuditTrailBuilder { + AuditTrailBuilder::new() + } + + pub async fn migrate(&self, _trail_id: ObjectID, _cap_id: ObjectID) -> Result<(), Error> { + Err(Error::NotImplemented("AuditTrailClient::migrate")) + } + + pub async fn delete_trail(&self, _trail_id: ObjectID, _cap_id: ObjectID) -> Result<(), Error> { + Err(Error::NotImplemented("AuditTrailClient::delete_trail")) + } +} + +#[async_trait::async_trait] +impl CoreClientReadOnly for AuditTrailClient { + fn package_id(&self) -> ObjectID { + self.read_client.package_id() + } + + fn network_name(&self) -> &NetworkName { + self.read_client.network() + } + + fn client_adapter(&self) -> &crate::iota_interaction_adapter::IotaClientAdapter { + self.read_client.iota_client() + } +} + +#[async_trait::async_trait] +impl CoreClient for AuditTrailClient +where + S: Signer + OptionalSync, +{ + fn signer(&self) -> &S { + &self.signer + } + + fn sender_address(&self) -> iota_interaction::types::base_types::IotaAddress { + iota_interaction::types::base_types::IotaAddress::from(&self.public_key) + } + + fn sender_public_key(&self) -> &iota_interaction::types::crypto::PublicKey { + &self.public_key + } } + +#[async_trait::async_trait] +impl AuditTrailReadOnly for AuditTrailClient +where + S: Signer + OptionalSync, +{ + async fn execute_read_only_transaction( + &self, + tx: ProgrammableTransaction, + ) -> Result { + self.read_client.execute_read_only_transaction(tx).await + } +} + +impl AuditTrailFull for AuditTrailClient where S: Signer + OptionalSync {} diff --git a/audit-trail-rs/src/client/mod.rs b/audit-trail-rs/src/client/mod.rs index c87ae7f9..79c06024 100644 --- a/audit-trail-rs/src/client/mod.rs +++ b/audit-trail-rs/src/client/mod.rs @@ -1,11 +1,7 @@ -// Copyright 2020-2025 IOTA Stiftung +// Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 //! Client implementations for interacting with audit trails on the IOTA blockchain. -//! -//! This module provides two client types: -//! - [`read_only`]: Read-only access to audit trail data -//! - [`full_client`]: Full read-write access with transaction capabilities use iota_interaction::IotaClientTrait; use product_common::network_name::NetworkName; diff --git a/audit-trail-rs/src/client/read_only.rs b/audit-trail-rs/src/client/read_only.rs index 1f590fd3..f37934da 100644 --- a/audit-trail-rs/src/client/read_only.rs +++ b/audit-trail-rs/src/client/read_only.rs @@ -1,22 +1,24 @@ -// Copyright 2020-2025 IOTA Stiftung +// Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 //! A read-only client for interacting with IOTA Audit Trail module objects. -//! -//! This client provides minimal setup to resolve the audit trail package ID -//! and basic access to the underlying IOTA client adapter. use std::ops::Deref; #[cfg(not(target_arch = "wasm32"))] use iota_interaction::IotaClient; -use iota_interaction::types::base_types::ObjectID; +use iota_interaction::IotaClientTrait; +use iota_interaction::types::base_types::{IotaAddress, ObjectID}; +use iota_interaction::types::transaction::{ProgrammableTransaction, TransactionKind}; #[cfg(target_arch = "wasm32")] use iota_interaction_ts::bindings::WasmIotaClient; +use product_common::core_client::CoreClientReadOnly; use product_common::network_name::NetworkName; use product_common::package_registry::Env; +use serde::de::DeserializeOwned; use super::network_id; +use crate::core::handler::{AuditTrailHandle, AuditTrailReadOnly}; use crate::error::Error; use crate::iota_interaction_adapter::IotaClientAdapter; use crate::package; @@ -62,6 +64,11 @@ impl AuditTrailClientReadOnly { &self.iota_client } + /// Returns a typed handle bound to a trail id. + pub fn trail<'a>(&'a self, trail_id: ObjectID) -> AuditTrailHandle<'a, Self> { + AuditTrailHandle::new(self, trail_id) + } + /// Attempts to create a new [`AuditTrailClientReadOnly`] from a given IOTA client. /// /// This resolves the package ID from the internal registry based on the network. @@ -126,3 +133,48 @@ impl AuditTrailClientReadOnly { Self::new_internal(client, network).await } } + +#[async_trait::async_trait] +impl CoreClientReadOnly for AuditTrailClientReadOnly { + fn package_id(&self) -> ObjectID { + self.audit_trail_pkg_id + } + + fn network_name(&self) -> &NetworkName { + &self.network + } + + fn client_adapter(&self) -> &IotaClientAdapter { + &self.iota_client + } +} + +#[async_trait::async_trait] +impl AuditTrailReadOnly for AuditTrailClientReadOnly { + async fn execute_read_only_transaction( + &self, + tx: ProgrammableTransaction, + ) -> Result { + let inspection_result = self + .iota_client + .read_api() + .dev_inspect_transaction_block(IotaAddress::ZERO, TransactionKind::programmable(tx), None, None, None) + .await + .map_err(|err| Error::UnexpectedApiResponse(format!("Failed to inspect transaction block: {err}")))?; + + let execution_results = inspection_result + .results + .ok_or_else(|| Error::UnexpectedApiResponse("DevInspectResults missing 'results' field".to_string()))?; + + let (return_value_bytes, _) = execution_results + .first() + .ok_or_else(|| Error::UnexpectedApiResponse("Execution results list is empty".to_string()))? + .return_values + .first() + .ok_or_else(|| Error::InvalidArgument("should have at least one return value".to_string()))?; + + let deserialized_output = bcs::from_bytes::(return_value_bytes)?; + + Ok(deserialized_output) + } +} diff --git a/audit-trail-rs/src/core/builder.rs b/audit-trail-rs/src/core/builder.rs new file mode 100644 index 00000000..2a76c396 --- /dev/null +++ b/audit-trail-rs/src/core/builder.rs @@ -0,0 +1,72 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +//! Audit trail builder for creation transactions. + +use product_common::transaction::transaction_builder::TransactionBuilder; + +use super::transactions::CreateTrail; +use super::types::{Data, ImmutableMetadata, LockingConfig}; +use crate::error::Error; + +/// Builder for creating an audit trail. +#[derive(Debug, Clone)] +pub struct AuditTrailBuilder { + pub initial_data: Option, + pub initial_record_metadata: Option, + pub locking_config: LockingConfig, + pub trail_metadata: Option, + pub updatable_metadata: Option, +} + +impl AuditTrailBuilder { + /// Creates a new builder. + pub fn new() -> Self { + Self { + initial_data: None, + initial_record_metadata: None, + locking_config: LockingConfig::none(), + trail_metadata: None, + updatable_metadata: None, + } + } + + /// Sets the initial record data and optional record metadata. + pub fn with_initial_record(mut self, data: Data, metadata: Option) -> Self { + self.initial_data = Some(data); + self.initial_record_metadata = metadata; + self + } + + /// Sets the locking configuration for the trail. + pub fn with_locking_config(mut self, config: LockingConfig) -> Self { + self.locking_config = config; + self + } + + /// Sets immutable metadata for the trail. + pub fn with_trail_metadata(mut self, metadata: ImmutableMetadata) -> Self { + self.trail_metadata = Some(metadata); + self + } + + /// Sets immutable metadata by parts. + pub fn with_trail_metadata_parts(mut self, name: impl Into, description: Option) -> Self { + self.trail_metadata = Some(ImmutableMetadata { + name: name.into(), + description, + }); + self + } + + /// Sets updatable metadata for the trail. + pub fn with_updatable_metadata(mut self, metadata: impl Into) -> Self { + self.updatable_metadata = Some(metadata.into()); + self + } + + /// Finalizes the builder and creates a transaction builder. + pub fn finish(self) -> Result, Error> { + Ok(TransactionBuilder::new(CreateTrail::new(self))) + } +} diff --git a/audit-trail-rs/src/core/handler/capabilities.rs b/audit-trail-rs/src/core/handler/capabilities.rs new file mode 100644 index 00000000..19cb6ea8 --- /dev/null +++ b/audit-trail-rs/src/core/handler/capabilities.rs @@ -0,0 +1,40 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use iota_interaction::types::base_types::ObjectID; + +use super::AuditTrailFull; +use crate::error::Error; + +#[derive(Debug, Clone)] +pub struct TrailCapabilities<'a, C> { + pub(crate) client: &'a C, + pub(crate) trail_id: ObjectID, +} + +impl<'a, C> TrailCapabilities<'a, C> { + pub(crate) fn new(client: &'a C, trail_id: ObjectID) -> Self { + Self { client, trail_id } + } + + pub async fn issue(&self, _cap_id: ObjectID, _role: String) -> Result<(), Error> + where + C: AuditTrailFull, + { + Err(Error::NotImplemented("TrailCapabilities::issue")) + } + + pub async fn revoke(&self, _cap_id: ObjectID, _capability_id: ObjectID) -> Result<(), Error> + where + C: AuditTrailFull, + { + Err(Error::NotImplemented("TrailCapabilities::revoke")) + } + + pub async fn destroy(&self, _capability_id: ObjectID) -> Result<(), Error> + where + C: AuditTrailFull, + { + Err(Error::NotImplemented("TrailCapabilities::destroy")) + } +} diff --git a/audit-trail-rs/src/core/handler/handle.rs b/audit-trail-rs/src/core/handler/handle.rs new file mode 100644 index 00000000..96308ce4 --- /dev/null +++ b/audit-trail-rs/src/core/handler/handle.rs @@ -0,0 +1,50 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +//! Typed handle wrappers bound to a specific trail id and client reference. + +use iota_interaction::types::base_types::ObjectID; + +use super::capabilities::TrailCapabilities; +use super::locking::TrailLocking; +use super::metadata::TrailMetadata; +use super::records::TrailRecords; +use super::roles::TrailRoles; +use crate::core::types::Data; + +/// A typed handle bound to a specific audit trail and client. +#[derive(Debug, Clone)] +pub struct AuditTrailHandle<'a, C> { + pub(crate) client: &'a C, + pub(crate) trail_id: ObjectID, +} + +impl<'a, C> AuditTrailHandle<'a, C> { + pub(crate) fn new(client: &'a C, trail_id: ObjectID) -> Self { + Self { client, trail_id } + } + + pub fn records(&self) -> TrailRecords<'a, C, Data> { + TrailRecords::new(self.client, self.trail_id) + } + + pub fn records_as(&self) -> TrailRecords<'a, C, D> { + TrailRecords::new(self.client, self.trail_id) + } + + pub fn locking(&self) -> TrailLocking<'a, C> { + TrailLocking::new(self.client, self.trail_id) + } + + pub fn metadata(&self) -> TrailMetadata<'a, C> { + TrailMetadata::new(self.client, self.trail_id) + } + + pub fn roles(&self) -> TrailRoles<'a, C> { + TrailRoles::new(self.client, self.trail_id) + } + + pub fn capabilities(&self) -> TrailCapabilities<'a, C> { + TrailCapabilities::new(self.client, self.trail_id) + } +} diff --git a/audit-trail-rs/src/core/handler/locking.rs b/audit-trail-rs/src/core/handler/locking.rs new file mode 100644 index 00000000..40eef7f2 --- /dev/null +++ b/audit-trail-rs/src/core/handler/locking.rs @@ -0,0 +1,41 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use iota_interaction::types::base_types::ObjectID; + +use super::{AuditTrailFull, AuditTrailReadOnly}; +use crate::core::types::{LockingConfig, LockingWindow}; +use crate::error::Error; + +#[derive(Debug, Clone)] +pub struct TrailLocking<'a, C> { + pub(crate) client: &'a C, + pub(crate) trail_id: ObjectID, +} + +impl<'a, C> TrailLocking<'a, C> { + pub(crate) fn new(client: &'a C, trail_id: ObjectID) -> Self { + Self { client, trail_id } + } + + pub async fn update(&self, _cap_id: ObjectID, _config: LockingConfig) -> Result<(), Error> + where + C: AuditTrailFull, + { + Err(Error::NotImplemented("TrailLocking::update")) + } + + pub async fn update_delete_record_window(&self, _cap_id: ObjectID, _window: LockingWindow) -> Result<(), Error> + where + C: AuditTrailFull, + { + Err(Error::NotImplemented("TrailLocking::update_delete_record_window")) + } + + pub async fn is_record_locked(&self, _sequence_number: u64) -> Result + where + C: AuditTrailReadOnly, + { + Err(Error::NotImplemented("TrailLocking::is_record_locked")) + } +} diff --git a/audit-trail-rs/src/core/handler/metadata.rs b/audit-trail-rs/src/core/handler/metadata.rs new file mode 100644 index 00000000..ab2864c9 --- /dev/null +++ b/audit-trail-rs/src/core/handler/metadata.rs @@ -0,0 +1,26 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use iota_interaction::types::base_types::ObjectID; + +use super::AuditTrailFull; +use crate::error::Error; + +#[derive(Debug, Clone)] +pub struct TrailMetadata<'a, C> { + pub(crate) client: &'a C, + pub(crate) trail_id: ObjectID, +} + +impl<'a, C> TrailMetadata<'a, C> { + pub(crate) fn new(client: &'a C, trail_id: ObjectID) -> Self { + Self { client, trail_id } + } + + pub async fn update(&self, _metadata: Option) -> Result<(), Error> + where + C: AuditTrailFull, + { + Err(Error::NotImplemented("TrailMetadata::update")) + } +} diff --git a/audit-trail-rs/src/core/handler/mod.rs b/audit-trail-rs/src/core/handler/mod.rs new file mode 100644 index 00000000..07a78360 --- /dev/null +++ b/audit-trail-rs/src/core/handler/mod.rs @@ -0,0 +1,34 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +pub mod capabilities; +pub mod handle; +pub mod locking; +pub mod metadata; +pub mod records; +pub mod roles; + +pub use capabilities::TrailCapabilities; +pub use handle::AuditTrailHandle; +use iota_interaction::OptionalSync; +use iota_interaction::types::transaction::ProgrammableTransaction; +pub use locking::TrailLocking; +pub use metadata::TrailMetadata; +use product_common::core_client::CoreClientReadOnly; +pub use records::TrailRecords; +pub use roles::TrailRoles; +use serde::de::DeserializeOwned; + +use crate::error::Error; + +/// Marker trait for read-only audit trail clients. +#[doc(hidden)] +#[async_trait::async_trait] +pub trait AuditTrailReadOnly: CoreClientReadOnly + OptionalSync { + async fn execute_read_only_transaction(&self, tx: ProgrammableTransaction) + -> Result; +} + +/// Marker trait for full (read-write) audit trail clients. +#[doc(hidden)] +pub trait AuditTrailFull: AuditTrailReadOnly {} diff --git a/audit-trail-rs/src/core/handler/records.rs b/audit-trail-rs/src/core/handler/records.rs new file mode 100644 index 00000000..a59f3e36 --- /dev/null +++ b/audit-trail-rs/src/core/handler/records.rs @@ -0,0 +1,139 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use iota_interaction::types::base_types::ObjectID; +use iota_interaction::{IotaKeySignature, OptionalSync}; +use product_common::core_client::CoreClient; +use product_common::transaction::transaction_builder::TransactionBuilder; +use secret_storage::Signer; +use serde::de::DeserializeOwned; + +use super::{AuditTrailFull, AuditTrailReadOnly}; +use crate::core::operations::AuditTrailImpl; +use crate::core::transactions::{AddRecord, DeleteRecord}; +use crate::core::types::{Data, Record}; +use crate::error::Error; + +#[derive(Debug, Clone)] +pub struct TrailRecords<'a, C, D = Data> { + pub(crate) client: &'a C, + pub(crate) trail_id: ObjectID, + pub(crate) _phantom: std::marker::PhantomData, +} + +impl<'a, C, D> TrailRecords<'a, C, D> { + pub(crate) fn new(client: &'a C, trail_id: ObjectID) -> Self { + Self { + client, + trail_id, + _phantom: std::marker::PhantomData, + } + } + + pub async fn get(&self, sequence_number: u64) -> Result, Error> + where + C: AuditTrailReadOnly, + D: DeserializeOwned, + { + let tx = AuditTrailImpl::get_record(self.client, self.trail_id, sequence_number).await?; + self.client.execute_read_only_transaction(tx).await + } + + pub async fn list(&self) -> Result>, Error> + where + C: AuditTrailReadOnly, + D: DeserializeOwned, + { + let first = self.first_sequence().await?; + let last = self.last_sequence().await?; + + let Some(first_seq) = first else { + return Ok(Vec::new()); + }; + let Some(last_seq) = last else { + return Ok(Vec::new()); + }; + + let mut records = Vec::new(); + for seq in first_seq..=last_seq { + if self.has_record(seq).await? { + records.push(self.get(seq).await?); + } + } + + Ok(records) + } + + pub fn add(&self, data: D, metadata: Option) -> Result, Error> + where + C: AuditTrailFull + CoreClient, + S: Signer + OptionalSync, + D: Into, + { + let owner = self.client.sender_address(); + Ok(TransactionBuilder::new(AddRecord::new( + self.trail_id, + owner, + data.into(), + metadata, + ))) + } + + pub fn delete(&self, sequence_number: u64) -> Result, Error> + where + C: AuditTrailFull + CoreClient, + S: Signer + OptionalSync, + { + let owner = self.client.sender_address(); + Ok(TransactionBuilder::new(DeleteRecord::new( + self.trail_id, + owner, + sequence_number, + ))) + } + + pub async fn correct( + &self, + _cap_id: ObjectID, + _replaces: Vec, + _data: D, + _metadata: Option, + ) -> Result<(), Error> + where + C: AuditTrailFull, + { + Err(Error::NotImplemented("TrailRecords::correct")) + } + + async fn has_record(&self, sequence_number: u64) -> Result + where + C: AuditTrailReadOnly, + { + let tx = AuditTrailImpl::has_record(self.client, self.trail_id, sequence_number).await?; + self.client.execute_read_only_transaction(tx).await + } + + async fn first_sequence(&self) -> Result, Error> + where + C: AuditTrailReadOnly, + { + let tx = AuditTrailImpl::first_sequence(self.client, self.trail_id).await?; + self.client.execute_read_only_transaction(tx).await + } + + async fn last_sequence(&self) -> Result, Error> + where + C: AuditTrailReadOnly, + { + let tx = AuditTrailImpl::last_sequence(self.client, self.trail_id).await?; + self.client.execute_read_only_transaction(tx).await + } + + pub async fn record_count(&self) -> Result + where + C: AuditTrailReadOnly, + { + let tx = AuditTrailImpl::record_count(self.client, self.trail_id).await?; + self.client.execute_read_only_transaction(tx).await + } +} diff --git a/audit-trail-rs/src/core/handler/roles.rs b/audit-trail-rs/src/core/handler/roles.rs new file mode 100644 index 00000000..20ca6035 --- /dev/null +++ b/audit-trail-rs/src/core/handler/roles.rs @@ -0,0 +1,96 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use iota_interaction::types::base_types::ObjectID; + +use super::AuditTrailFull; +use crate::core::types::PermissionSet; +use crate::error::Error; + +#[derive(Debug, Clone)] +pub struct TrailRoles<'a, C> { + pub(crate) client: &'a C, + pub(crate) trail_id: ObjectID, +} + +impl<'a, C> TrailRoles<'a, C> { + pub(crate) fn new(client: &'a C, trail_id: ObjectID) -> Self { + Self { client, trail_id } + } + + /// Returns a handle bound to a specific role name. + pub fn role(&self, name: impl Into) -> RoleHandle<'a, C> { + RoleHandle::new(self.client, self.trail_id, name.into()) + } + + /// Creates a new role with the provided permissions. + pub async fn create( + &self, + _cap_id: ObjectID, + _name: impl Into, + _permissions: PermissionSet, + ) -> Result<(), Error> + where + C: AuditTrailFull, + { + Err(Error::NotImplemented("TrailRoles::create")) + } + + /// Updates permissions for an existing role. + pub async fn update( + &self, + _cap_id: ObjectID, + _name: impl Into, + _permissions: PermissionSet, + ) -> Result<(), Error> + where + C: AuditTrailFull, + { + Err(Error::NotImplemented("TrailRoles::update")) + } + + /// Deletes an existing role. + pub async fn delete(&self, _cap_id: ObjectID, _name: impl Into) -> Result<(), Error> + where + C: AuditTrailFull, + { + Err(Error::NotImplemented("TrailRoles::delete")) + } +} + +#[derive(Debug, Clone)] +pub struct RoleHandle<'a, C> { + pub(crate) client: &'a C, + pub(crate) trail_id: ObjectID, + pub(crate) name: String, +} + +impl<'a, C> RoleHandle<'a, C> { + pub(crate) fn new(client: &'a C, trail_id: ObjectID, name: String) -> Self { + Self { + client, + trail_id, + name, + } + } + + pub fn name(&self) -> &str { + &self.name + } + + /// Updates permissions for this role. + pub async fn update_permissions(&self, _cap_id: ObjectID, _permissions: PermissionSet) -> Result<(), Error> + where + C: AuditTrailFull, + { + Err(Error::NotImplemented("RoleHandle::update_permissions")) + } + + /// Deletes this role. + pub async fn delete(&self, _cap_id: ObjectID) -> Result<(), Error> + where + C: AuditTrailFull, + { + Err(Error::NotImplemented("RoleHandle::delete")) + } +} diff --git a/audit-trail-rs/src/core/mod.rs b/audit-trail-rs/src/core/mod.rs index ad7fe9a5..003ad1ff 100644 --- a/audit-trail-rs/src/core/mod.rs +++ b/audit-trail-rs/src/core/mod.rs @@ -3,6 +3,9 @@ //! Core types and builders for audit trails. +pub mod builder; +pub mod handler; +pub(crate) mod move_utils; +pub(crate) mod operations; +pub mod transactions; pub mod types; - -pub use types::*; diff --git a/audit-trail-rs/src/core/move_utils.rs b/audit-trail-rs/src/core/move_utils.rs new file mode 100644 index 00000000..35ee4172 --- /dev/null +++ b/audit-trail-rs/src/core/move_utils.rs @@ -0,0 +1,121 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use std::str::FromStr; + +use iota_interaction::rpc_types::IotaObjectDataOptions; +use iota_interaction::types::base_types::{ObjectID, ObjectRef}; +use iota_interaction::types::object::Owner; +use iota_interaction::types::programmable_transaction_builder::ProgrammableTransactionBuilder as Ptb; +use iota_interaction::types::transaction::{Argument, ObjectArg}; +use iota_interaction::types::{IOTA_CLOCK_OBJECT_ID, IOTA_CLOCK_OBJECT_SHARED_VERSION, TypeTag}; +use iota_interaction::{IotaClientTrait, OptionalSync}; +use product_common::core_client::CoreClientReadOnly; +use serde::Serialize; + +use crate::error::Error; + +/// Adds a reference to the on-chain clock to `ptb`'s arguments. +pub(crate) fn get_clock_ref(ptb: &mut Ptb) -> Argument { + ptb.obj(ObjectArg::SharedObject { + id: IOTA_CLOCK_OBJECT_ID, + initial_shared_version: IOTA_CLOCK_OBJECT_SHARED_VERSION, + mutable: false, + }) + .expect("network has a singleton clock instantiated") +} + +pub(crate) fn ptb_pure(ptb: &mut Ptb, name: &str, value: T) -> Result +where + T: Serialize + core::fmt::Debug, +{ + ptb.pure(&value).map_err(|err| { + Error::InvalidArgument(format!( + r"could not serialize pure value {name} with value {value:?}; {err}" + )) + }) +} + +/// Get the type tag of an object +pub(crate) async fn get_type_tag(client: &C, object_id: &ObjectID) -> Result +where + C: CoreClientReadOnly + OptionalSync, +{ + let object_response = client + .client_adapter() + .read_api() + .get_object_with_options(*object_id, IotaObjectDataOptions::new().with_type()) + .await + .map_err(|err| Error::FailedToParseTag(format!("Failed to get object: {err}")))?; + + let object_data = object_response + .data + .ok_or_else(|| Error::FailedToParseTag(format!("Object {object_id} not found")))?; + + let full_type_str = object_data + .object_type() + .map_err(|e| Error::FailedToParseTag(format!("Failed to get object type: {e}")))? + .to_string(); + + let type_param_str = parse_type(&full_type_str)?; + + let tag = TypeTag::from_str(&type_param_str) + .map_err(|e| Error::FailedToParseTag(format!("Failed to parse tag '{type_param_str}': {e}")))?; + + Ok(tag) +} + +/// Parses the type string to get the generic argument +pub(crate) fn parse_type(full_type: &str) -> Result { + if let (Some(start), Some(end)) = (full_type.find('<'), full_type.rfind('>')) { + Ok(full_type[start + 1..end].to_string()) + } else { + Err(Error::FailedToParseTag(format!( + "Could not parse type parameter from {full_type}" + ))) + } +} + +pub(crate) async fn get_object_ref_by_id( + iota_client: &impl CoreClientReadOnly, + obj: &ObjectID, +) -> Result { + let res = iota_client + .client_adapter() + .read_api() + .get_object_with_options(*obj, IotaObjectDataOptions::new().with_content()) + .await + .map_err(|err| Error::GenericError(format!("Failed to get object: {err}")))?; + + let Some(data) = res.data else { + return Err(Error::InvalidArgument("no data found".to_string())); + }; + + Ok(data.object_ref()) +} + +pub(crate) async fn get_shared_object_arg( + iota_client: &impl CoreClientReadOnly, + obj: &ObjectID, + mutable: bool, +) -> Result { + let res = iota_client + .client_adapter() + .read_api() + .get_object_with_options(*obj, IotaObjectDataOptions::new().with_owner()) + .await + .map_err(|err| Error::GenericError(format!("Failed to get object: {err}")))?; + + let Some(data) = res.data else { + return Err(Error::InvalidArgument("no data found".to_string())); + }; + + match data.owner { + Some(Owner::Shared { initial_shared_version }) => Ok(ObjectArg::SharedObject { + id: *obj, + initial_shared_version, + mutable, + }), + _ => Err(Error::InvalidArgument("object is not shared".to_string())), + } +} diff --git a/audit-trail-rs/src/core/operations/create.rs b/audit-trail-rs/src/core/operations/create.rs new file mode 100644 index 00000000..f79d95da --- /dev/null +++ b/audit-trail-rs/src/core/operations/create.rs @@ -0,0 +1,56 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use iota_interaction::ident_str; +use iota_interaction::types::base_types::ObjectID; +use iota_interaction::types::transaction::ProgrammableTransaction; + +use super::AuditTrailImpl; +use crate::core::move_utils; +use crate::core::types::{Data, ImmutableMetadata, LockingConfig}; +use crate::error::Error; + +impl AuditTrailImpl { + pub(crate) fn create_trail( + package_id: ObjectID, + initial_data: Option, + initial_record_metadata: Option, + locking_config: LockingConfig, + trail_metadata: Option, + updatable_metadata: Option, + ) -> Result { + let mut ptb = iota_interaction::types::programmable_transaction_builder::ProgrammableTransactionBuilder::new(); + + let initial_data_arg = match initial_data { + Some(data) => move_utils::ptb_pure(&mut ptb, "initial_data", Some(data))?, + None => move_utils::ptb_pure::>(&mut ptb, "initial_data", None)?, + }; + + let initial_record_metadata = + move_utils::ptb_pure(&mut ptb, "initial_record_metadata", initial_record_metadata)?; + let locking_config = move_utils::ptb_pure(&mut ptb, "locking_config", locking_config)?; + let trail_metadata = match trail_metadata { + Some(metadata) => move_utils::ptb_pure(&mut ptb, "trail_metadata", Some(metadata))?, + None => move_utils::ptb_pure::>(&mut ptb, "trail_metadata", None)?, + }; + let updatable_metadata = move_utils::ptb_pure(&mut ptb, "updatable_metadata", updatable_metadata)?; + let clock = move_utils::get_clock_ref(&mut ptb); + + ptb.programmable_move_call( + package_id, + ident_str!("main").into(), + ident_str!("create").into(), + vec![], // TODO: Add type tag for initial data + vec![ + initial_data_arg, + initial_record_metadata, + locking_config, + trail_metadata, + updatable_metadata, + clock, + ], + ); + + Ok(ptb.finish()) + } +} diff --git a/audit-trail-rs/src/core/operations/locking.rs b/audit-trail-rs/src/core/operations/locking.rs new file mode 100644 index 00000000..760209ca --- /dev/null +++ b/audit-trail-rs/src/core/operations/locking.rs @@ -0,0 +1,54 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use iota_interaction::OptionalSync; +use iota_interaction::types::base_types::ObjectID; +use iota_interaction::types::transaction::ProgrammableTransaction; +use product_common::core_client::CoreClientReadOnly; + +use super::AuditTrailImpl; +use crate::core::move_utils; +use crate::core::types::LockingWindow; +use crate::error::Error; + +impl AuditTrailImpl { + pub(crate) async fn update_locking_config( + client: &C, + trail_id: ObjectID, + cap_id: ObjectID, + new_config: crate::core::types::LockingConfig, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + Self::build_trail_transaction(client, trail_id, cap_id, "update_locking_config", |ptb| { + let config = move_utils::ptb_pure(ptb, "new_config", new_config)?; + let clock = move_utils::get_clock_ref(ptb); + Ok(vec![config, clock]) + }) + .await + } + + pub(crate) async fn update_locking_config_for_delete_record( + client: &C, + trail_id: ObjectID, + cap_id: ObjectID, + window: LockingWindow, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + Self::build_trail_transaction( + client, + trail_id, + cap_id, + "update_locking_config_for_delete_record", + |ptb| { + let window = move_utils::ptb_pure(ptb, "new_delete_record_lock", window)?; + let clock = move_utils::get_clock_ref(ptb); + Ok(vec![window, clock]) + }, + ) + .await + } +} diff --git a/audit-trail-rs/src/core/operations/metadata.rs b/audit-trail-rs/src/core/operations/metadata.rs new file mode 100644 index 00000000..77f35c41 --- /dev/null +++ b/audit-trail-rs/src/core/operations/metadata.rs @@ -0,0 +1,30 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use iota_interaction::OptionalSync; +use iota_interaction::types::base_types::ObjectID; +use iota_interaction::types::transaction::ProgrammableTransaction; +use product_common::core_client::CoreClientReadOnly; + +use super::AuditTrailImpl; +use crate::core::move_utils; +use crate::error::Error; + +impl AuditTrailImpl { + pub(crate) async fn update_metadata( + client: &C, + trail_id: ObjectID, + cap_id: ObjectID, + new_metadata: Option, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + Self::build_trail_transaction(client, trail_id, cap_id, "update_metadata", |ptb| { + let meta = move_utils::ptb_pure(ptb, "new_metadata", new_metadata)?; + let clock = move_utils::get_clock_ref(ptb); + Ok(vec![meta, clock]) + }) + .await + } +} diff --git a/audit-trail-rs/src/core/operations/migrate.rs b/audit-trail-rs/src/core/operations/migrate.rs new file mode 100644 index 00000000..54be051f --- /dev/null +++ b/audit-trail-rs/src/core/operations/migrate.rs @@ -0,0 +1,28 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use iota_interaction::OptionalSync; +use iota_interaction::types::base_types::ObjectID; +use iota_interaction::types::transaction::ProgrammableTransaction; +use product_common::core_client::CoreClientReadOnly; + +use super::AuditTrailImpl; +use crate::core::move_utils; +use crate::error::Error; + +impl AuditTrailImpl { + pub(crate) async fn migrate( + client: &C, + trail_id: ObjectID, + cap_id: ObjectID, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + Self::build_trail_transaction(client, trail_id, cap_id, "migrate", |ptb| { + let clock = move_utils::get_clock_ref(ptb); + Ok(vec![clock]) + }) + .await + } +} diff --git a/audit-trail-rs/src/core/operations/mod.rs b/audit-trail-rs/src/core/operations/mod.rs new file mode 100644 index 00000000..505f97d6 --- /dev/null +++ b/audit-trail-rs/src/core/operations/mod.rs @@ -0,0 +1,136 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +//! Audit trail operations for building Move transactions. + +use std::str::FromStr; + +use iota_interaction::types::base_types::{IotaAddress, ObjectID, ObjectRef}; +use iota_interaction::types::programmable_transaction_builder::ProgrammableTransactionBuilder; +use iota_interaction::types::transaction::{Argument, ObjectArg, ProgrammableTransaction}; +use iota_interaction::{OptionalSync, ident_str}; +use product_common::core_client::CoreClientReadOnly; + +use crate::core::move_utils; +use crate::core::types::Capability; +use crate::error::Error; + +pub(crate) mod create; +pub(crate) mod locking; +pub(crate) mod metadata; +pub(crate) mod migrate; +pub(crate) mod records; + +#[derive(Debug, Clone)] +pub(crate) struct AuditTrailImpl; + +impl AuditTrailImpl { + async fn build_trail_transaction( + client: &C, + trail_id: ObjectID, + cap_id: ObjectID, + method: impl AsRef, + additional_args: F, + ) -> Result + where + F: FnOnce(&mut ProgrammableTransactionBuilder) -> Result, Error>, + C: CoreClientReadOnly + OptionalSync, + { + let cap_ref = move_utils::get_object_ref_by_id(client, &cap_id).await?; + Self::build_trail_transaction_with_cap_ref(client, trail_id, cap_ref, method, additional_args).await + } + + async fn build_trail_transaction_for_owner( + client: &C, + trail_id: ObjectID, + owner: IotaAddress, + method: impl AsRef, + additional_args: F, + ) -> Result + where + F: FnOnce(&mut ProgrammableTransactionBuilder) -> Result, Error>, + C: CoreClientReadOnly + OptionalSync, + { + let cap_ref = Self::get_capability_ref(client, owner, trail_id).await?; + Self::build_trail_transaction_with_cap_ref(client, trail_id, cap_ref, method, additional_args).await + } + + async fn build_trail_transaction_with_cap_ref( + client: &C, + trail_id: ObjectID, + cap_ref: ObjectRef, + method: impl AsRef, + additional_args: F, + ) -> Result + where + F: FnOnce(&mut ProgrammableTransactionBuilder) -> Result, Error>, + C: CoreClientReadOnly + OptionalSync, + { + let mut ptb = ProgrammableTransactionBuilder::new(); + + let tag = vec![move_utils::get_type_tag(client, &trail_id).await?]; + let trail_arg = move_utils::get_shared_object_arg(client, &trail_id, true).await?; + + let mut args = vec![ + ptb.obj(trail_arg) + .map_err(|e| Error::InvalidArgument(format!("Failed to create trail argument: {e}")))?, + ptb.obj(ObjectArg::ImmOrOwnedObject(cap_ref)) + .map_err(|e| Error::InvalidArgument(format!("Failed to create cap argument: {e}")))?, + ]; + + args.extend(additional_args(&mut ptb)?); + + let function = iota_interaction::types::Identifier::from_str(method.as_ref()) + .map_err(|e| Error::InvalidArgument(format!("Invalid method name '{}': {e}", method.as_ref())))?; + + ptb.programmable_move_call(client.package_id(), ident_str!("main").into(), function, tag, args); + + Ok(ptb.finish()) + } + + async fn get_capability_ref(client: &C, owner: IotaAddress, trail_id: ObjectID) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + let cap: Capability = client + .find_object_for_address(owner, |cap: &Capability| cap.security_vault_id == trail_id) + .await + .map_err(|e| Error::RpcError(e.to_string()))? + .ok_or_else(|| { + Error::InvalidArgument(format!("no capability found for owner {owner} and trail {trail_id}")) + })?; + + let object_id = *cap.id.object_id(); + move_utils::get_object_ref_by_id(client, &object_id).await + } + + async fn build_read_only_transaction( + client: &C, + trail_id: ObjectID, + method: impl AsRef, + additional_args: F, + ) -> Result + where + F: FnOnce(&mut ProgrammableTransactionBuilder) -> Result, Error>, + C: CoreClientReadOnly + OptionalSync, + { + let mut ptb = ProgrammableTransactionBuilder::new(); + + let tag = vec![move_utils::get_type_tag(client, &trail_id).await?]; + let trail_arg = move_utils::get_shared_object_arg(client, &trail_id, false).await?; + + let mut args = vec![ + ptb.obj(trail_arg) + .map_err(|e| Error::InvalidArgument(format!("Failed to create trail argument: {e}")))?, + ]; + + args.extend(additional_args(&mut ptb)?); + + let function = iota_interaction::types::Identifier::from_str(method.as_ref()) + .map_err(|e| Error::InvalidArgument(format!("Invalid method name '{}': {e}", method.as_ref())))?; + + ptb.programmable_move_call(client.package_id(), ident_str!("main").into(), function, tag, args); + + Ok(ptb.finish()) + } +} diff --git a/audit-trail-rs/src/core/operations/records.rs b/audit-trail-rs/src/core/operations/records.rs new file mode 100644 index 00000000..4e9f9dfe --- /dev/null +++ b/audit-trail-rs/src/core/operations/records.rs @@ -0,0 +1,104 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use iota_interaction::OptionalSync; +use iota_interaction::types::base_types::{IotaAddress, ObjectID}; +use iota_interaction::types::transaction::ProgrammableTransaction; +use product_common::core_client::CoreClientReadOnly; + +use super::AuditTrailImpl; +use crate::core::move_utils; +use crate::core::types::Data; +use crate::error::Error; + +impl AuditTrailImpl { + pub(crate) async fn add_record( + client: &C, + trail_id: ObjectID, + owner: IotaAddress, + data: Data, + record_metadata: Option, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + Self::build_trail_transaction_for_owner(client, trail_id, owner, "add_record", |ptb| { + let data_arg = match data { + Data::Bytes(bytes) => move_utils::ptb_pure(ptb, "stored_data", bytes)?, + Data::Text(text) => move_utils::ptb_pure(ptb, "stored_data", text)?, + }; + let metadata = move_utils::ptb_pure(ptb, "record_metadata", record_metadata)?; + let clock = move_utils::get_clock_ref(ptb); + Ok(vec![data_arg, metadata, clock]) + }) + .await + } + + pub(crate) async fn delete_record( + client: &C, + trail_id: ObjectID, + owner: IotaAddress, + sequence_number: u64, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + Self::build_trail_transaction_for_owner(client, trail_id, owner, "delete_record", |ptb| { + let seq = move_utils::ptb_pure(ptb, "sequence_number", sequence_number)?; + let clock = move_utils::get_clock_ref(ptb); + Ok(vec![seq, clock]) + }) + .await + } + + pub(crate) async fn get_record( + client: &C, + trail_id: ObjectID, + sequence_number: u64, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + Self::build_read_only_transaction(client, trail_id, "get_record", |ptb| { + let seq = move_utils::ptb_pure(ptb, "sequence_number", sequence_number)?; + Ok(vec![seq]) + }) + .await + } + + pub(crate) async fn has_record( + client: &C, + trail_id: ObjectID, + sequence_number: u64, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + Self::build_read_only_transaction(client, trail_id, "has_record", |ptb| { + let seq = move_utils::ptb_pure(ptb, "sequence_number", sequence_number)?; + Ok(vec![seq]) + }) + .await + } + + pub(crate) async fn record_count(client: &C, trail_id: ObjectID) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + Self::build_read_only_transaction(client, trail_id, "record_count", |_| Ok(vec![])).await + } + + pub(crate) async fn first_sequence(client: &C, trail_id: ObjectID) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + Self::build_read_only_transaction(client, trail_id, "first_sequence", |_| Ok(vec![])).await + } + + pub(crate) async fn last_sequence(client: &C, trail_id: ObjectID) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + Self::build_read_only_transaction(client, trail_id, "last_sequence", |_| Ok(vec![])).await + } +} diff --git a/audit-trail-rs/src/core/transactions/create.rs b/audit-trail-rs/src/core/transactions/create.rs new file mode 100644 index 00000000..d7769f19 --- /dev/null +++ b/audit-trail-rs/src/core/transactions/create.rs @@ -0,0 +1,121 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use async_trait::async_trait; +use iota_interaction::OptionalSync; +use iota_interaction::rpc_types::{ + IotaTransactionBlockEffects, IotaTransactionBlockEffectsAPI, IotaTransactionBlockEvents, +}; +use iota_interaction::types::base_types::ObjectID; +use iota_interaction::types::transaction::ProgrammableTransaction; +use product_common::core_client::CoreClientReadOnly; +use product_common::transaction::transaction_builder::Transaction; +use tokio::sync::OnceCell; + +use crate::core::builder::AuditTrailBuilder; +use crate::core::operations::AuditTrailImpl; +use crate::core::types::{AuditTrailCreated, Capability, Event}; +use crate::error::Error; + +/// Output of a create trail transaction. +#[derive(Debug, Clone)] +pub struct TrailCreated { + pub trail_id: ObjectID, + pub admin_capability_id: Option, + pub creator: iota_interaction::types::base_types::IotaAddress, + pub timestamp: u64, + pub has_initial_record: bool, +} + +/// A transaction that creates a new audit trail. +#[derive(Debug, Clone)] +pub struct CreateTrail { + builder: AuditTrailBuilder, + cached_ptb: OnceCell, +} + +impl CreateTrail { + /// Creates a new [`CreateTrail`] instance. + pub fn new(builder: AuditTrailBuilder) -> Self { + Self { + builder, + cached_ptb: OnceCell::new(), + } + } + + async fn make_ptb(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + let AuditTrailBuilder { + initial_data, + initial_record_metadata, + locking_config, + trail_metadata, + updatable_metadata, + } = self.builder.clone(); + + AuditTrailImpl::create_trail( + client.package_id(), + initial_data, + initial_record_metadata, + locking_config, + trail_metadata, + updatable_metadata, + ) + } +} + +#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] +#[cfg_attr(feature = "send-sync", async_trait)] +impl Transaction for CreateTrail { + type Error = Error; + type Output = TrailCreated; + + async fn build_programmable_transaction(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() + } + + async fn apply_with_events( + mut self, + effects: &mut IotaTransactionBlockEffects, + events: &mut IotaTransactionBlockEvents, + client: &C, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + let event = events + .data + .iter() + .find_map(|data| serde_json::from_value::>(data.parsed_json.clone()).ok()) + .ok_or_else(|| Error::UnexpectedApiResponse("AuditTrailCreated event not found".to_string()))?; + + let mut admin_capability_id = None; + for created in effects.created() { + let object_id = created.object_id(); + if let Ok(capability) = client.get_object_by_id::(object_id).await { + admin_capability_id = Some(*capability.id.object_id()); + break; + } + } + + Ok(TrailCreated { + trail_id: event.data.trail_id, + admin_capability_id, + creator: event.data.creator, + timestamp: event.data.timestamp, + has_initial_record: event.data.has_initial_record, + }) + } + + async fn apply(mut self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + unreachable!() + } +} diff --git a/audit-trail-rs/src/core/transactions/mod.rs b/audit-trail-rs/src/core/transactions/mod.rs new file mode 100644 index 00000000..f8360f90 --- /dev/null +++ b/audit-trail-rs/src/core/transactions/mod.rs @@ -0,0 +1,8 @@ +// Copyright 2020-2025 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +pub mod create; +pub mod record; + +pub use create::*; +pub use record::*; diff --git a/audit-trail-rs/src/core/transactions/record.rs b/audit-trail-rs/src/core/transactions/record.rs new file mode 100644 index 00000000..c6a53e8c --- /dev/null +++ b/audit-trail-rs/src/core/transactions/record.rs @@ -0,0 +1,156 @@ +// Copyright 2020-2025 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use async_trait::async_trait; +use iota_interaction::OptionalSync; +use iota_interaction::rpc_types::{IotaTransactionBlockEffects, IotaTransactionBlockEvents}; +use iota_interaction::types::base_types::{IotaAddress, ObjectID}; +use iota_interaction::types::transaction::ProgrammableTransaction; +use product_common::core_client::CoreClientReadOnly; +use product_common::transaction::transaction_builder::Transaction; +use tokio::sync::OnceCell; + +use crate::core::operations::AuditTrailImpl; +use crate::core::types::{Data, Event, RecordAdded, RecordDeleted}; +use crate::error::Error; + +#[derive(Debug, Clone)] +pub struct AddRecord { + pub trail_id: ObjectID, + pub owner: IotaAddress, + pub data: Data, + pub metadata: Option, + cached_ptb: OnceCell, +} + +impl AddRecord { + pub fn new(trail_id: ObjectID, owner: IotaAddress, data: Data, metadata: Option) -> Self { + Self { + trail_id, + owner, + data, + metadata, + cached_ptb: OnceCell::new(), + } + } + + async fn make_ptb(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + AuditTrailImpl::add_record( + client, + self.trail_id, + self.owner, + self.data.clone(), + self.metadata.clone(), + ) + .await + } +} + +#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] +#[cfg_attr(feature = "send-sync", async_trait)] +impl Transaction for AddRecord { + type Error = Error; + type Output = RecordAdded; + + async fn build_programmable_transaction(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() + } + + async fn apply_with_events( + mut self, + _: &mut IotaTransactionBlockEffects, + events: &mut IotaTransactionBlockEvents, + _: &C, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + for data in &events.data { + if let Ok(event) = serde_json::from_value::>(data.parsed_json.clone()) { + return Ok(event.data); + } + } + + Err(Error::UnexpectedApiResponse("RecordAdded event not found".to_string())) + } + + async fn apply(mut self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + unreachable!() + } +} + +#[derive(Debug, Clone)] +pub struct DeleteRecord { + pub trail_id: ObjectID, + pub owner: IotaAddress, + pub sequence_number: u64, + cached_ptb: OnceCell, +} + +impl DeleteRecord { + pub fn new(trail_id: ObjectID, owner: IotaAddress, sequence_number: u64) -> Self { + Self { + trail_id, + owner, + sequence_number, + cached_ptb: OnceCell::new(), + } + } + + async fn make_ptb(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + AuditTrailImpl::delete_record(client, self.trail_id, self.owner, self.sequence_number).await + } +} + +#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] +#[cfg_attr(feature = "send-sync", async_trait)] +impl Transaction for DeleteRecord { + type Error = Error; + type Output = RecordDeleted; + + async fn build_programmable_transaction(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() + } + + async fn apply_with_events( + mut self, + _: &mut IotaTransactionBlockEffects, + events: &mut IotaTransactionBlockEvents, + _: &C, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + for data in &events.data { + if let Ok(event) = serde_json::from_value::>(data.parsed_json.clone()) { + return Ok(event.data); + } + } + + Err(Error::UnexpectedApiResponse( + "RecordDeleted event not found".to_string(), + )) + } + + async fn apply(mut self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + unreachable!() + } +} diff --git a/audit-trail-rs/src/core/types/audit_trail.rs b/audit-trail-rs/src/core/types/audit_trail.rs index d3ed39ca..d11117ba 100644 --- a/audit-trail-rs/src/core/types/audit_trail.rs +++ b/audit-trail-rs/src/core/types/audit_trail.rs @@ -1,4 +1,4 @@ -// Copyright 2020-2025 IOTA Stiftung +// Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 use iota_interaction::types::base_types::IotaAddress; @@ -13,7 +13,7 @@ use super::role_map::RoleMap; /// An audit trail stored on-chain. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct AuditTrail { +pub struct AuditTrail { pub id: UID, pub creator: IotaAddress, pub created_at: u64, diff --git a/audit-trail-rs/src/core/types/capability.rs b/audit-trail-rs/src/core/types/capability.rs index e3957192..24ea55f7 100644 --- a/audit-trail-rs/src/core/types/capability.rs +++ b/audit-trail-rs/src/core/types/capability.rs @@ -1,9 +1,12 @@ -// Copyright 2020-2025 IOTA Stiftung +// Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 use iota_interaction::types::base_types::{IotaAddress, ObjectID}; use iota_interaction::types::id::UID; +use iota_interaction::types::TypeTag; +use iota_interaction::MoveType; use serde::{Deserialize, Serialize}; +use std::str::FromStr; /// Capability data returned by the Move capability module. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -15,3 +18,10 @@ pub struct Capability { pub valid_from: Option, pub valid_until: Option, } + +impl MoveType for Capability { + fn move_type(package: ObjectID) -> TypeTag { + TypeTag::from_str(format!("{package}::capability::Capability").as_str()) + .expect("failed to create type tag") + } +} diff --git a/audit-trail-rs/src/core/types/event.rs b/audit-trail-rs/src/core/types/event.rs index 6e8e3291..28861523 100644 --- a/audit-trail-rs/src/core/types/event.rs +++ b/audit-trail-rs/src/core/types/event.rs @@ -1,4 +1,4 @@ -// Copyright 2020-2025 IOTA Stiftung +// Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 use iota_interaction::types::base_types::{IotaAddress, ObjectID}; diff --git a/audit-trail-rs/src/core/types/locking.rs b/audit-trail-rs/src/core/types/locking.rs index fa650459..f7739bcb 100644 --- a/audit-trail-rs/src/core/types/locking.rs +++ b/audit-trail-rs/src/core/types/locking.rs @@ -1,4 +1,4 @@ -// Copyright 2020-2025 IOTA Stiftung +// Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 use serde::{Deserialize, Serialize}; diff --git a/audit-trail-rs/src/core/types/metadata.rs b/audit-trail-rs/src/core/types/metadata.rs index 49fc9892..62a8ac26 100644 --- a/audit-trail-rs/src/core/types/metadata.rs +++ b/audit-trail-rs/src/core/types/metadata.rs @@ -1,4 +1,4 @@ -// Copyright 2020-2025 IOTA Stiftung +// Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 use serde::{Deserialize, Serialize}; diff --git a/audit-trail-rs/src/core/types/mod.rs b/audit-trail-rs/src/core/types/mod.rs index 4518bdb7..472e3ea3 100644 --- a/audit-trail-rs/src/core/types/mod.rs +++ b/audit-trail-rs/src/core/types/mod.rs @@ -1,4 +1,4 @@ -// Copyright 2020-2025 IOTA Stiftung +// Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 //! Core data types for audit trails. diff --git a/audit-trail-rs/src/core/types/permission.rs b/audit-trail-rs/src/core/types/permission.rs index 24d4d128..bd2c8b82 100644 --- a/audit-trail-rs/src/core/types/permission.rs +++ b/audit-trail-rs/src/core/types/permission.rs @@ -1,4 +1,4 @@ -// Copyright 2020-2025 IOTA Stiftung +// Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 use serde::{Deserialize, Serialize}; diff --git a/audit-trail-rs/src/core/types/record.rs b/audit-trail-rs/src/core/types/record.rs index a8b0dd60..de854bae 100644 --- a/audit-trail-rs/src/core/types/record.rs +++ b/audit-trail-rs/src/core/types/record.rs @@ -1,35 +1,173 @@ -// Copyright 2020-2025 IOTA Stiftung +// Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +use std::str::FromStr; + use iota_interaction::types::base_types::IotaAddress; -use serde::{Deserialize, Serialize}; +use iota_interaction::types::{MOVE_STDLIB_PACKAGE_ID, TypeTag}; +use serde::{Deserialize, Deserializer, Serialize}; + +use crate::error::Error; use super::record_correction::RecordCorrection; -/// Supported record data types. +/// A single record in the audit trail. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub enum RecordData { +pub struct Record { + pub data: D, + pub metadata: Option, + pub sequence_number: u64, + pub added_by: IotaAddress, + pub added_at: u64, + pub correction: RecordCorrection, +} + +/// Supported record data types. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub enum Data { Bytes(Vec), Text(String), } -impl RecordData { +impl<'de> Deserialize<'de> for Data { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + // Handle both raw bytes and string representations from BCS + let bytes = Vec::::deserialize(deserializer)?; + + if let Ok(text) = String::from_utf8(bytes.clone()) { + // Additional check: if it looks like actual text (not just valid UTF-8 bytes) + if text.chars().all(|c| c.is_ascii_graphic() || c.is_ascii_whitespace()) { + Ok(Data::Text(text)) + } else { + Ok(Data::Bytes(bytes)) + } + } else { + Ok(Data::Bytes(bytes)) + } + } +} + +impl Data { + /// Returns the Move type tag for this data type. + pub(crate) fn tag(&self) -> TypeTag { + match self { + Data::Bytes(_) => TypeTag::Vector(Box::new(TypeTag::U8)), + Data::Text(_) => TypeTag::from_str(&format!("{MOVE_STDLIB_PACKAGE_ID}::string::String")) + .expect("should be valid type tag"), + } + } + + /// Creates a new `Data` from bytes. pub fn bytes(data: impl Into>) -> Self { Self::Bytes(data.into()) } + /// Creates a new `Data` from text. pub fn text(data: impl Into) -> Self { Self::Text(data.into()) } + + /// Extracts the data as bytes. + /// + /// ## Errors + /// + /// Returns an error if the data is text rather than bytes. + pub fn as_bytes(self) -> Result, Error> { + match self { + Data::Bytes(data) => Ok(data), + Data::Text(_) => Err(Error::GenericError("Data is not bytes".to_string())), + } + } + + /// Extracts the data as text. + /// + /// ## Errors + /// + /// Returns an error if the data is bytes rather than text. + pub fn as_text(self) -> Result { + match self { + Data::Bytes(_) => Err(Error::GenericError("Data is not text".to_string())), + Data::Text(data) => Ok(data), + } + } } -/// A single record in the audit trail. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct Record { - pub data: D, - pub metadata: Option, - pub sequence_number: u64, - pub added_by: IotaAddress, - pub added_at: u64, - pub correction: RecordCorrection, +impl From for Data { + fn from(value: String) -> Self { + Data::Text(value) + } +} + +impl From<&str> for Data { + fn from(value: &str) -> Self { + Data::Text(value.to_string()) + } +} + +impl From> for Data { + fn from(value: Vec) -> Self { + Data::Bytes(value) + } +} + +impl From<&[u8]> for Data { + fn from(value: &[u8]) -> Self { + Data::Bytes(value.to_vec()) + } +} + +#[cfg(test)] +mod tests { + use super::Data; + + fn deserialize_from_raw_bytes(payload: Vec) -> Data { + let encoded = bcs::to_bytes(&payload).expect("failed to bcs encode bytes payload"); + bcs::from_bytes::(&encoded).expect("failed to deserialize Data from bcs payload") + } + + #[test] + fn deserialize_ascii_text_returns_text_variant() { + let data = deserialize_from_raw_bytes(b"hello world".to_vec()); + assert_eq!(data, Data::Text("hello world".to_string())); + } + + #[test] + fn deserialize_ascii_text_with_whitespace_returns_text_variant() { + let data = deserialize_from_raw_bytes(b"line 1\nline 2\tend".to_vec()); + assert_eq!(data, Data::Text("line 1\nline 2\tend".to_string())); + } + + #[test] + fn deserialize_non_ascii_utf8_returns_bytes_variant() { + let data = deserialize_from_raw_bytes("olá mundo".as_bytes().to_vec()); + assert_eq!(data, Data::Bytes("olá mundo".as_bytes().to_vec())); + } + + #[test] + fn deserialize_ascii_like_binary_returns_text_variant() { + // Demonstrates current heuristic limitation: printable ASCII payloads are interpreted as text. + let data = deserialize_from_raw_bytes(b"GIF89a".to_vec()); + assert_eq!(data, Data::Text("GIF89a".to_string())); + } + + #[test] + fn deserialize_utf8_with_control_chars_returns_bytes_variant() { + let data = deserialize_from_raw_bytes(vec![b'a', b'b', 0x00, b'c']); + assert_eq!(data, Data::Bytes(vec![b'a', b'b', 0x00, b'c'])); + } + + #[test] + fn deserialize_invalid_utf8_returns_bytes_variant() { + let data = deserialize_from_raw_bytes(vec![0xF0, 0x28, 0x8C, 0x28]); + assert_eq!(data, Data::Bytes(vec![0xF0, 0x28, 0x8C, 0x28])); + } + + #[test] + fn deserialize_empty_payload_returns_empty_text() { + let data = deserialize_from_raw_bytes(Vec::new()); + assert_eq!(data, Data::Text(String::new())); + } } diff --git a/audit-trail-rs/src/core/types/record_correction.rs b/audit-trail-rs/src/core/types/record_correction.rs index 750359fe..dbb93540 100644 --- a/audit-trail-rs/src/core/types/record_correction.rs +++ b/audit-trail-rs/src/core/types/record_correction.rs @@ -1,4 +1,4 @@ -// Copyright 2020-2025 IOTA Stiftung +// Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 use std::collections::HashSet; diff --git a/audit-trail-rs/src/core/types/role_map.rs b/audit-trail-rs/src/core/types/role_map.rs index 319ee944..28a12937 100644 --- a/audit-trail-rs/src/core/types/role_map.rs +++ b/audit-trail-rs/src/core/types/role_map.rs @@ -1,4 +1,4 @@ -// Copyright 2020-2025 IOTA Stiftung +// Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 use iota_interaction::types::base_types::ObjectID; diff --git a/audit-trail-rs/src/error.rs b/audit-trail-rs/src/error.rs index bd8ca9c5..79f75834 100644 --- a/audit-trail-rs/src/error.rs +++ b/audit-trail-rs/src/error.rs @@ -1,4 +1,4 @@ -// Copyright 2020-2025 IOTA Stiftung +// Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 use crate::iota_interaction_adapter::AdapterError; @@ -37,6 +37,9 @@ pub enum Error { /// Failed to deserialize data using BCS. #[error("BCS deserialization error: {0}")] DeserializationError(#[from] bcs::Error), + /// The response from the IOTA node API was not in the expected format. + #[error("unexpected transaction response: {0}")] + TransactionUnexpectedResponse(String), } #[cfg(target_arch = "wasm32")] diff --git a/audit-trail-rs/src/iota_interaction_adapter.rs b/audit-trail-rs/src/iota_interaction_adapter.rs index 7eb7e409..ec0d9f0a 100644 --- a/audit-trail-rs/src/iota_interaction_adapter.rs +++ b/audit-trail-rs/src/iota_interaction_adapter.rs @@ -1,4 +1,4 @@ -// Copyright 2020-2025 IOTA Stiftung +// Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 // The following platform compile switch provides all the diff --git a/audit-trail-rs/src/lib.rs b/audit-trail-rs/src/lib.rs index a4ca47e5..29a01d11 100644 --- a/audit-trail-rs/src/lib.rs +++ b/audit-trail-rs/src/lib.rs @@ -1,4 +1,4 @@ -// Copyright 2020-2025 IOTA Stiftung +// Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 pub mod client; diff --git a/audit-trail-rs/src/package.rs b/audit-trail-rs/src/package.rs index 605028b8..ce800ff8 100644 --- a/audit-trail-rs/src/package.rs +++ b/audit-trail-rs/src/package.rs @@ -1,4 +1,4 @@ -// Copyright 2020-2025 IOTA Stiftung +// Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 //! Package management for audit trail smart contracts. diff --git a/audit-trail-rs/tests/e2e/client.rs b/audit-trail-rs/tests/e2e/client.rs new file mode 100644 index 00000000..2fc9912d --- /dev/null +++ b/audit-trail-rs/tests/e2e/client.rs @@ -0,0 +1,111 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use std::ops::Deref; +use std::str::FromStr; +use std::sync::Arc; + +use audit_trails::{AuditTrailClient, AuditTrailClientReadOnly}; +use audit_trails::core::types::Data; +use iota_interaction::types::base_types::{IotaAddress, ObjectID}; +use iota_interaction::types::crypto::PublicKey; +use iota_interaction::{IOTA_LOCAL_NETWORK_URL, IotaClientBuilder, KeytoolSigner}; +use iota_interaction_rust::IotaClientAdapter; +use product_common::core_client::{CoreClient, CoreClientReadOnly}; +use product_common::network_name::NetworkName; +use product_common::test_utils::{ + TEST_GAS_BUDGET, get_active_address, get_balance, init_product_package, request_funds, +}; + +pub const ENV_PACKAGE_ID: &str = "AUDIT_TRAIL_PACKAGE_ID"; +pub const ENV_RECORD_TYPE: &str = "AUDIT_TRAIL_RECORD_TYPE"; + +/// Script file for publishing the package. +pub const PUBLISH_SCRIPT_FILE: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../audit-trail-move/scripts/publish_package.sh" +); + +pub async fn get_funded_test_client() -> anyhow::Result { + TestClient::new().await +} + +#[derive(Clone)] +pub struct TestClient { + client: Arc>, +} + +impl Deref for TestClient { + type Target = AuditTrailClient; + fn deref(&self) -> &Self::Target { + &self.client + } +} + +impl TestClient { + pub async fn new() -> anyhow::Result { + let active_address = get_active_address().await?; + Self::new_from_address(active_address).await + } + + pub async fn new_from_address(address: IotaAddress) -> anyhow::Result { + let api_endpoint = std::env::var("API_ENDPOINT").unwrap_or_else(|_| IOTA_LOCAL_NETWORK_URL.to_string()); + let client = IotaClientBuilder::default().build(&api_endpoint).await?; + let package_id = match std::env::var(ENV_PACKAGE_ID) { + Ok(value) => ObjectID::from_str(&value)?, + Err(_) => init_product_package(&client, None, Some(PUBLISH_SCRIPT_FILE)).await?, + }; + + let balance = get_balance(address).await?; + if balance < TEST_GAS_BUDGET { + request_funds(&address).await?; + } + + let read_client = AuditTrailClientReadOnly::new_with_pkg_id(client, package_id).await?; + let signer = KeytoolSigner::builder().build()?; + let client = AuditTrailClient::new(read_client, signer).await?; + + Ok(TestClient { + client: Arc::new(client), + }) + } +} + +impl CoreClientReadOnly for TestClient { + fn package_id(&self) -> ObjectID { + self.client.package_id() + } + + fn network_name(&self) -> &NetworkName { + self.client.network_name() + } + + fn client_adapter(&self) -> &IotaClientAdapter { + self.client.client_adapter() + } +} + +impl CoreClient for TestClient { + fn signer(&self) -> &KeytoolSigner { + self.client.signer() + } + + fn sender_address(&self) -> IotaAddress { + self.client.sender_address() + } + + fn sender_public_key(&self) -> &PublicKey { + self.client.sender_public_key() + } +} + +pub fn record_data_from_env() -> Data { + match std::env::var(ENV_RECORD_TYPE).as_deref() { + Ok("bytes") => Data::bytes(b"audit-trail-test".to_vec()), + Ok("text") | Err(_) => Data::text("audit-trail-test"), + Ok(other) => { + eprintln!("Unknown {ENV_RECORD_TYPE} value '{other}', defaulting to text."); + Data::text("audit-trail-test") + } + } +} diff --git a/audit-trail-rs/tests/e2e/main.rs b/audit-trail-rs/tests/e2e/main.rs new file mode 100644 index 00000000..f06ff3ca --- /dev/null +++ b/audit-trail-rs/tests/e2e/main.rs @@ -0,0 +1,5 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +mod client; +mod records; diff --git a/audit-trail-rs/tests/e2e/records.rs b/audit-trail-rs/tests/e2e/records.rs new file mode 100644 index 00000000..d72c0b41 --- /dev/null +++ b/audit-trail-rs/tests/e2e/records.rs @@ -0,0 +1,51 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use crate::client::{get_funded_test_client, record_data_from_env}; +use audit_trails::core::types::Data; + +#[tokio::test] +async fn add_and_fetch_record_roundtrip() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let data = record_data_from_env(); + let metadata = Some("audit-trail-e2e".to_string()); + + let created = client + .create_trail() + .with_initial_record(data.clone(), metadata.clone()) + .finish()? + .build_and_execute(&client) + .await? + .output; + + let trail_id = created.trail_id; + assert!( + created.admin_capability_id.is_some(), + "admin capability id should be returned" + ); + + let output = client + .trail(trail_id) + .records() + .add(data.clone(), metadata.clone())? + .build_and_execute(&client) + .await?; + + let added = output.output; + assert_eq!(added.trail_id, trail_id); + + let record = client.trail(trail_id).records().get(added.sequence_number).await?; + assert_eq!(record.sequence_number, added.sequence_number); + assert_eq!(record.metadata, metadata); + assert_record_data_eq(record.data, data); + + Ok(()) +} + +fn assert_record_data_eq(actual: Data, expected: Data) { + match (actual, expected) { + (Data::Bytes(a), Data::Bytes(b)) => assert_eq!(a, b), + (Data::Text(a), Data::Text(b)) => assert_eq!(a, b), + (a, b) => panic!("record data type mismatch: actual={a:?}, expected={b:?}"), + } +} From b58f170e63d98b9481f63f5ce77675fcccf327fd Mon Sep 17 00:00:00 2001 From: Yasir Date: Mon, 9 Feb 2026 12:35:13 +0300 Subject: [PATCH 048/189] refactor: Simplify function signatures in client and handler modules --- audit-trail-rs/src/client/full_client.rs | 4 +-- .../src/core/handler/capabilities.rs | 4 +-- audit-trail-rs/src/core/handler/locking.rs | 4 +-- audit-trail-rs/src/core/handler/records.rs | 8 +----- audit-trail-rs/src/core/handler/roles.rs | 26 +++++-------------- audit-trail-rs/src/core/types/audit_trail.rs | 4 ++- .../src/core/types/record_correction.rs | 9 +------ 7 files changed, 17 insertions(+), 42 deletions(-) diff --git a/audit-trail-rs/src/client/full_client.rs b/audit-trail-rs/src/client/full_client.rs index 73c5235f..b8ac7364 100644 --- a/audit-trail-rs/src/client/full_client.rs +++ b/audit-trail-rs/src/client/full_client.rs @@ -67,11 +67,11 @@ impl AuditTrailClient { AuditTrailBuilder::new() } - pub async fn migrate(&self, _trail_id: ObjectID, _cap_id: ObjectID) -> Result<(), Error> { + pub async fn migrate(&self, _trail_id: ObjectID) -> Result<(), Error> { Err(Error::NotImplemented("AuditTrailClient::migrate")) } - pub async fn delete_trail(&self, _trail_id: ObjectID, _cap_id: ObjectID) -> Result<(), Error> { + pub async fn delete_trail(&self, _trail_id: ObjectID) -> Result<(), Error> { Err(Error::NotImplemented("AuditTrailClient::delete_trail")) } } diff --git a/audit-trail-rs/src/core/handler/capabilities.rs b/audit-trail-rs/src/core/handler/capabilities.rs index 19cb6ea8..1d1fb6b1 100644 --- a/audit-trail-rs/src/core/handler/capabilities.rs +++ b/audit-trail-rs/src/core/handler/capabilities.rs @@ -17,14 +17,14 @@ impl<'a, C> TrailCapabilities<'a, C> { Self { client, trail_id } } - pub async fn issue(&self, _cap_id: ObjectID, _role: String) -> Result<(), Error> + pub async fn issue(&self, _role: String) -> Result<(), Error> where C: AuditTrailFull, { Err(Error::NotImplemented("TrailCapabilities::issue")) } - pub async fn revoke(&self, _cap_id: ObjectID, _capability_id: ObjectID) -> Result<(), Error> + pub async fn revoke(&self, _capability_id: ObjectID) -> Result<(), Error> where C: AuditTrailFull, { diff --git a/audit-trail-rs/src/core/handler/locking.rs b/audit-trail-rs/src/core/handler/locking.rs index 40eef7f2..1435f64e 100644 --- a/audit-trail-rs/src/core/handler/locking.rs +++ b/audit-trail-rs/src/core/handler/locking.rs @@ -18,14 +18,14 @@ impl<'a, C> TrailLocking<'a, C> { Self { client, trail_id } } - pub async fn update(&self, _cap_id: ObjectID, _config: LockingConfig) -> Result<(), Error> + pub async fn update(&self, _config: LockingConfig) -> Result<(), Error> where C: AuditTrailFull, { Err(Error::NotImplemented("TrailLocking::update")) } - pub async fn update_delete_record_window(&self, _cap_id: ObjectID, _window: LockingWindow) -> Result<(), Error> + pub async fn update_delete_record_window(&self, _window: LockingWindow) -> Result<(), Error> where C: AuditTrailFull, { diff --git a/audit-trail-rs/src/core/handler/records.rs b/audit-trail-rs/src/core/handler/records.rs index a59f3e36..c8c39bc7 100644 --- a/audit-trail-rs/src/core/handler/records.rs +++ b/audit-trail-rs/src/core/handler/records.rs @@ -92,13 +92,7 @@ impl<'a, C, D> TrailRecords<'a, C, D> { ))) } - pub async fn correct( - &self, - _cap_id: ObjectID, - _replaces: Vec, - _data: D, - _metadata: Option, - ) -> Result<(), Error> + pub async fn correct(&self, _replaces: Vec, _data: D, _metadata: Option) -> Result<(), Error> where C: AuditTrailFull, { diff --git a/audit-trail-rs/src/core/handler/roles.rs b/audit-trail-rs/src/core/handler/roles.rs index 20ca6035..ef781065 100644 --- a/audit-trail-rs/src/core/handler/roles.rs +++ b/audit-trail-rs/src/core/handler/roles.rs @@ -24,12 +24,7 @@ impl<'a, C> TrailRoles<'a, C> { } /// Creates a new role with the provided permissions. - pub async fn create( - &self, - _cap_id: ObjectID, - _name: impl Into, - _permissions: PermissionSet, - ) -> Result<(), Error> + pub async fn create(&self, _name: impl Into, _permissions: PermissionSet) -> Result<(), Error> where C: AuditTrailFull, { @@ -37,12 +32,7 @@ impl<'a, C> TrailRoles<'a, C> { } /// Updates permissions for an existing role. - pub async fn update( - &self, - _cap_id: ObjectID, - _name: impl Into, - _permissions: PermissionSet, - ) -> Result<(), Error> + pub async fn update(&self, _name: impl Into, _permissions: PermissionSet) -> Result<(), Error> where C: AuditTrailFull, { @@ -50,7 +40,7 @@ impl<'a, C> TrailRoles<'a, C> { } /// Deletes an existing role. - pub async fn delete(&self, _cap_id: ObjectID, _name: impl Into) -> Result<(), Error> + pub async fn delete(&self, _name: impl Into) -> Result<(), Error> where C: AuditTrailFull, { @@ -67,11 +57,7 @@ pub struct RoleHandle<'a, C> { impl<'a, C> RoleHandle<'a, C> { pub(crate) fn new(client: &'a C, trail_id: ObjectID, name: String) -> Self { - Self { - client, - trail_id, - name, - } + Self { client, trail_id, name } } pub fn name(&self) -> &str { @@ -79,7 +65,7 @@ impl<'a, C> RoleHandle<'a, C> { } /// Updates permissions for this role. - pub async fn update_permissions(&self, _cap_id: ObjectID, _permissions: PermissionSet) -> Result<(), Error> + pub async fn update_permissions(&self, _permissions: PermissionSet) -> Result<(), Error> where C: AuditTrailFull, { @@ -87,7 +73,7 @@ impl<'a, C> RoleHandle<'a, C> { } /// Deletes this role. - pub async fn delete(&self, _cap_id: ObjectID) -> Result<(), Error> + pub async fn delete(&self) -> Result<(), Error> where C: AuditTrailFull, { diff --git a/audit-trail-rs/src/core/types/audit_trail.rs b/audit-trail-rs/src/core/types/audit_trail.rs index d11117ba..b77b205f 100644 --- a/audit-trail-rs/src/core/types/audit_trail.rs +++ b/audit-trail-rs/src/core/types/audit_trail.rs @@ -5,6 +5,8 @@ use iota_interaction::types::base_types::IotaAddress; use iota_interaction::types::id::UID; use serde::{Deserialize, Serialize}; +use crate::core::types::Data; + use super::locking::LockingConfig; use super::metadata::ImmutableMetadata; use super::permission::Permission; @@ -13,7 +15,7 @@ use super::role_map::RoleMap; /// An audit trail stored on-chain. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct AuditTrail { +pub struct AuditTrail { pub id: UID, pub creator: IotaAddress, pub created_at: u64, diff --git a/audit-trail-rs/src/core/types/record_correction.rs b/audit-trail-rs/src/core/types/record_correction.rs index dbb93540..640b511f 100644 --- a/audit-trail-rs/src/core/types/record_correction.rs +++ b/audit-trail-rs/src/core/types/record_correction.rs @@ -6,20 +6,13 @@ use std::collections::HashSet; use serde::{Deserialize, Serialize}; /// Bidirectional correction tracking for audit records. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] pub struct RecordCorrection { pub replaces: HashSet, pub is_replaced_by: Option, } impl RecordCorrection { - pub fn new() -> Self { - Self { - replaces: HashSet::new(), - is_replaced_by: None, - } - } - pub fn with_replaces(replaces: HashSet) -> Self { Self { replaces, From c15cdfc99df3a620cea4cbaf9af3b286eba8c725 Mon Sep 17 00:00:00 2001 From: Yasir Date: Mon, 9 Feb 2026 12:58:01 +0300 Subject: [PATCH 049/189] feat: Enhance AuditTrailClient with new identity client creation and error handling - Introduced `from_iota_client` method for creating an `AuditTrailClient` without signing capabilities. - Added `NoSigner` marker type and `FromIotaClientError` for improved error handling during client creation. - Updated `AuditTrailClient` to manage public keys and signers more effectively. - Enhanced `AuditTrailClientReadOnly` with a new method for custom package ID handling. --- audit-trail-rs/src/client/full_client.rs | 143 +++++++++++++++++++---- audit-trail-rs/src/client/read_only.rs | 5 +- 2 files changed, 123 insertions(+), 25 deletions(-) diff --git a/audit-trail-rs/src/client/full_client.rs b/audit-trail-rs/src/client/full_client.rs index b8ac7364..40e22e1d 100644 --- a/audit-trail-rs/src/client/full_client.rs +++ b/audit-trail-rs/src/client/full_client.rs @@ -11,21 +11,57 @@ use crate::client::read_only::AuditTrailClientReadOnly; use crate::core::builder::AuditTrailBuilder; use crate::core::handler::{AuditTrailFull, AuditTrailHandle, AuditTrailReadOnly}; use crate::error::Error; +use async_trait::async_trait; use iota_interaction::types::base_types::ObjectID; use iota_interaction::types::transaction::ProgrammableTransaction; use iota_interaction::{IotaKeySignature, OptionalSync}; +use iota_interaction_rust::IotaClientAdapter; +use iota_sdk::types::base_types::IotaAddress; use iota_sdk::types::crypto::PublicKey; use product_common::core_client::{CoreClient, CoreClientReadOnly}; use product_common::network_name::NetworkName; use secret_storage::Signer; use serde::de::DeserializeOwned; +#[cfg(not(target_arch = "wasm32"))] +use iota_interaction::IotaClient; +#[cfg(target_arch = "wasm32")] +use iota_interaction_ts::bindings::WasmIotaClient as IotaClient; + +/// A marker type indicating the absence of a signer. +#[derive(Debug, Clone, Copy)] +#[non_exhaustive] +pub struct NoSigner; + +/// The error that results from a failed attempt at creating an [IdentityClient] +/// from a given [IotaClient]. +#[derive(Debug, thiserror::Error)] +#[error("failed to create an 'IdentityClient' from the given 'IotaClient'")] +#[non_exhaustive] +pub struct FromIotaClientError { + /// Type of failure for this error. + #[source] + pub kind: FromIotaClientErrorKind, +} + +/// Types of failure for [FromIotaClientError]. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum FromIotaClientErrorKind { + /// A package ID is required, but was not supplied. + #[error("an IOTA Identity package ID must be supplied when connecting to an unofficial IOTA network")] + MissingPackageId, + /// Network ID resolution through an RPC call failed. + #[error("failed to resolve the network the given client is connected to")] + NetworkResolution(#[source] Box), +} + /// A full client that wraps the read-only client and hosts write operations. #[derive(Clone)] pub struct AuditTrailClient { - read_client: AuditTrailClientReadOnly, - public_key: PublicKey, - signer: S, + pub(super) read_client: AuditTrailClientReadOnly, + pub(super) public_key: Option, + pub(super) signer: S, } impl Deref for AuditTrailClient { @@ -35,25 +71,68 @@ impl Deref for AuditTrailClient { } } -impl AuditTrailClient -where - S: Signer, -{ - pub async fn new(client: AuditTrailClientReadOnly, signer: S) -> Result { - let public_key = signer - .public_key() - .await - .map_err(|e| Error::InvalidKey(e.to_string()))?; +impl AuditTrailClient { + /// Creates a new [AuditTrailClient], with **no** signing capabilities, from the given [IotaClient]. + /// + /// # Warning + /// Passing a `custom_package_id` is **only** required when connecting to a custom IOTA network. + /// + /// Relying on a custom Audit Trail package when connected to an official IOTA network is **highly + /// discouraged** and is sure to result in compatibility issues when interacting with other official + /// IOTA Trust Framework's products. + /// + /// # Examples + /// ``` + /// # use audit_trails::client::AuditTrailClient; + /// + /// # #[tokio::main] + /// # async fn main() -> anyhow::Result<()> { + /// let iota_client = iota_sdk::IotaClientBuilder::default() + /// .build_testnet() + /// .await?; + /// // No package ID is required since we are connecting to an official IOTA network. + /// let audit_trail_client = AuditTrailClient::from_iota_client(iota_client, None).await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn from_iota_client( + iota_client: IotaClient, + custom_package_id: impl Into>, + ) -> Result { + let read_only_client = if let Some(custom_package_id) = custom_package_id.into() { + AuditTrailClientReadOnly::new_with_pkg_id(iota_client, custom_package_id).await + } else { + AuditTrailClientReadOnly::new(iota_client).await + } + .map_err(|e| match e { + Error::InvalidConfig(_) => FromIotaClientErrorKind::MissingPackageId, + Error::RpcError(msg) => FromIotaClientErrorKind::NetworkResolution(msg.into()), + _ => unreachable!("'AuditTrailClientReadOnly::new' has been changed without updating error handling in 'AuditTrailClient::from_iota_client'"), + }) + .map_err(|kind| FromIotaClientError { kind })?; Ok(Self { - public_key, - read_client: client, - signer, + read_client: read_only_client, + public_key: None, + signer: NoSigner, }) } } impl AuditTrailClient { + /// Sets a new signer for this client. + pub async fn with_signer(self, signer: NewS) -> Result, secret_storage::Error> + where + NewS: Signer, + { + let public_key = signer.public_key().await?; + + Ok(AuditTrailClient { + read_client: self.read_client, + public_key: Some(public_key), + signer, + }) + } pub fn read_only(&self) -> &AuditTrailClientReadOnly { &self.read_client } @@ -76,7 +155,24 @@ impl AuditTrailClient { } } -#[async_trait::async_trait] +impl AuditTrailClient +where + S: Signer, +{ + /// Returns a reference to the [PublicKey] wrapped by this client. + pub fn public_key(&self) -> &PublicKey { + self.public_key.as_ref().expect("public_key is set") + } + + /// Returns the [IotaAddress] wrapped by this client. + #[inline(always)] + pub fn address(&self) -> IotaAddress { + IotaAddress::from(self.public_key()) + } +} + +#[cfg_attr(feature = "send-sync", async_trait)] +#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] impl CoreClientReadOnly for AuditTrailClient { fn package_id(&self) -> ObjectID { self.read_client.package_id() @@ -86,12 +182,13 @@ impl CoreClientReadOnly for AuditTrailClient { self.read_client.network() } - fn client_adapter(&self) -> &crate::iota_interaction_adapter::IotaClientAdapter { - self.read_client.iota_client() + fn client_adapter(&self) -> &IotaClientAdapter { + &self.read_client } } -#[async_trait::async_trait] +#[cfg_attr(feature = "send-sync", async_trait)] +#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] impl CoreClient for AuditTrailClient where S: Signer + OptionalSync, @@ -100,12 +197,12 @@ where &self.signer } - fn sender_address(&self) -> iota_interaction::types::base_types::IotaAddress { - iota_interaction::types::base_types::IotaAddress::from(&self.public_key) + fn sender_address(&self) -> IotaAddress { + IotaAddress::from(self.public_key()) } - fn sender_public_key(&self) -> &iota_interaction::types::crypto::PublicKey { - &self.public_key + fn sender_public_key(&self) -> &PublicKey { + self.public_key() } } diff --git a/audit-trail-rs/src/client/read_only.rs b/audit-trail-rs/src/client/read_only.rs index f37934da..c1d2eab4 100644 --- a/audit-trail-rs/src/client/read_only.rs +++ b/audit-trail-rs/src/client/read_only.rs @@ -115,8 +115,9 @@ impl AuditTrailClientReadOnly { /// Creates a new [`AuditTrailClientReadOnly`] with a specific audit trail package ID. /// - /// This function allows overriding the package ID lookup from the registry, which is useful - /// for connecting to networks where the package ID is known but not yet registered. + /// This function allows overriding the package ID lookup from the + /// registry, which is useful for connecting to networks where the package + /// ID is known but not yet registered, or for testing with custom deployments. pub async fn new_with_pkg_id( #[cfg(target_arch = "wasm32")] iota_client: WasmIotaClient, #[cfg(not(target_arch = "wasm32"))] iota_client: IotaClient, From 871a13229ead62cbb5b908926b3d682a8f40d67a Mon Sep 17 00:00:00 2001 From: Yasir Date: Mon, 9 Feb 2026 13:58:01 +0300 Subject: [PATCH 050/189] refactor: Update AuditTrailBuilder and related structures for default initialization - Replaced the custom `new` method in `AuditTrailBuilder` with the `Default` trait for simpler instantiation. - Added `Default` implementation to `LockingWindow` and `LockingConfig` structs for consistent default behavior. - Refactored tests to remove unused environment variable handling and directly use `Data::text` for record creation. --- audit-trail-rs/src/client/full_client.rs | 2 +- audit-trail-rs/src/core/builder.rs | 13 +------------ audit-trail-rs/src/core/types/locking.rs | 4 ++-- audit-trail-rs/tests/e2e/client.rs | 19 +++---------------- audit-trail-rs/tests/e2e/records.rs | 17 ++++------------- 5 files changed, 11 insertions(+), 44 deletions(-) diff --git a/audit-trail-rs/src/client/full_client.rs b/audit-trail-rs/src/client/full_client.rs index 40e22e1d..8dc3434d 100644 --- a/audit-trail-rs/src/client/full_client.rs +++ b/audit-trail-rs/src/client/full_client.rs @@ -143,7 +143,7 @@ impl AuditTrailClient { /// Creates a builder for an audit trail. pub fn create_trail(&self) -> AuditTrailBuilder { - AuditTrailBuilder::new() + AuditTrailBuilder::default() } pub async fn migrate(&self, _trail_id: ObjectID) -> Result<(), Error> { diff --git a/audit-trail-rs/src/core/builder.rs b/audit-trail-rs/src/core/builder.rs index 2a76c396..b80f4185 100644 --- a/audit-trail-rs/src/core/builder.rs +++ b/audit-trail-rs/src/core/builder.rs @@ -10,7 +10,7 @@ use super::types::{Data, ImmutableMetadata, LockingConfig}; use crate::error::Error; /// Builder for creating an audit trail. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Default)] pub struct AuditTrailBuilder { pub initial_data: Option, pub initial_record_metadata: Option, @@ -20,17 +20,6 @@ pub struct AuditTrailBuilder { } impl AuditTrailBuilder { - /// Creates a new builder. - pub fn new() -> Self { - Self { - initial_data: None, - initial_record_metadata: None, - locking_config: LockingConfig::none(), - trail_metadata: None, - updatable_metadata: None, - } - } - /// Sets the initial record data and optional record metadata. pub fn with_initial_record(mut self, data: Data, metadata: Option) -> Self { self.initial_data = Some(data); diff --git a/audit-trail-rs/src/core/types/locking.rs b/audit-trail-rs/src/core/types/locking.rs index f7739bcb..c7711856 100644 --- a/audit-trail-rs/src/core/types/locking.rs +++ b/audit-trail-rs/src/core/types/locking.rs @@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize}; /// Defines a locking window (time or count based). -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] pub struct LockingWindow { pub time_window_seconds: Option, pub count_window: Option, @@ -34,7 +34,7 @@ impl LockingWindow { } /// Locking configuration for the audit trail. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] pub struct LockingConfig { pub delete_record_lock: LockingWindow, } diff --git a/audit-trail-rs/tests/e2e/client.rs b/audit-trail-rs/tests/e2e/client.rs index 2fc9912d..eb1e60a6 100644 --- a/audit-trail-rs/tests/e2e/client.rs +++ b/audit-trail-rs/tests/e2e/client.rs @@ -5,8 +5,7 @@ use std::ops::Deref; use std::str::FromStr; use std::sync::Arc; -use audit_trails::{AuditTrailClient, AuditTrailClientReadOnly}; -use audit_trails::core::types::Data; +use audit_trails::AuditTrailClient; use iota_interaction::types::base_types::{IotaAddress, ObjectID}; use iota_interaction::types::crypto::PublicKey; use iota_interaction::{IOTA_LOCAL_NETWORK_URL, IotaClientBuilder, KeytoolSigner}; @@ -18,7 +17,6 @@ use product_common::test_utils::{ }; pub const ENV_PACKAGE_ID: &str = "AUDIT_TRAIL_PACKAGE_ID"; -pub const ENV_RECORD_TYPE: &str = "AUDIT_TRAIL_RECORD_TYPE"; /// Script file for publishing the package. pub const PUBLISH_SCRIPT_FILE: &str = concat!( @@ -61,9 +59,9 @@ impl TestClient { request_funds(&address).await?; } - let read_client = AuditTrailClientReadOnly::new_with_pkg_id(client, package_id).await?; let signer = KeytoolSigner::builder().build()?; - let client = AuditTrailClient::new(read_client, signer).await?; + let client = AuditTrailClient::from_iota_client(client.clone(), Some(package_id)).await?; + let client = client.with_signer(signer).await?; Ok(TestClient { client: Arc::new(client), @@ -98,14 +96,3 @@ impl CoreClient for TestClient { self.client.sender_public_key() } } - -pub fn record_data_from_env() -> Data { - match std::env::var(ENV_RECORD_TYPE).as_deref() { - Ok("bytes") => Data::bytes(b"audit-trail-test".to_vec()), - Ok("text") | Err(_) => Data::text("audit-trail-test"), - Ok(other) => { - eprintln!("Unknown {ENV_RECORD_TYPE} value '{other}', defaulting to text."); - Data::text("audit-trail-test") - } - } -} diff --git a/audit-trail-rs/tests/e2e/records.rs b/audit-trail-rs/tests/e2e/records.rs index d72c0b41..e6390a82 100644 --- a/audit-trail-rs/tests/e2e/records.rs +++ b/audit-trail-rs/tests/e2e/records.rs @@ -1,18 +1,17 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use crate::client::{get_funded_test_client, record_data_from_env}; +use crate::client::get_funded_test_client; use audit_trails::core::types::Data; #[tokio::test] async fn add_and_fetch_record_roundtrip() -> anyhow::Result<()> { let client = get_funded_test_client().await?; - let data = record_data_from_env(); let metadata = Some("audit-trail-e2e".to_string()); let created = client .create_trail() - .with_initial_record(data.clone(), metadata.clone()) + .with_initial_record(Data::text("audit-trail-e2e"), metadata.clone()) .finish()? .build_and_execute(&client) .await? @@ -27,7 +26,7 @@ async fn add_and_fetch_record_roundtrip() -> anyhow::Result<()> { let output = client .trail(trail_id) .records() - .add(data.clone(), metadata.clone())? + .add(Data::text("audit-trail-e2e"), metadata.clone())? .build_and_execute(&client) .await?; @@ -37,15 +36,7 @@ async fn add_and_fetch_record_roundtrip() -> anyhow::Result<()> { let record = client.trail(trail_id).records().get(added.sequence_number).await?; assert_eq!(record.sequence_number, added.sequence_number); assert_eq!(record.metadata, metadata); - assert_record_data_eq(record.data, data); + assert_eq!(record.data, Data::text("audit-trail-e2e")); Ok(()) } - -fn assert_record_data_eq(actual: Data, expected: Data) { - match (actual, expected) { - (Data::Bytes(a), Data::Bytes(b)) => assert_eq!(a, b), - (Data::Text(a), Data::Text(b)) => assert_eq!(a, b), - (a, b) => panic!("record data type mismatch: actual={a:?}, expected={b:?}"), - } -} From d77519b8715682aa4b890f775f1f641e8aff8e73 Mon Sep 17 00:00:00 2001 From: Yasir Date: Mon, 9 Feb 2026 14:43:01 +0300 Subject: [PATCH 051/189] refactor: Restructure audit trail core modules and update imports - Moved `AuditTrailHandle`, `AuditTrailFull`, and `AuditTrailReadOnly` traits to a new `trail` module for better organization. - Introduced new modules for capabilities, creation, locking, metadata, records, and roles to enhance modularity. - Updated import paths across the codebase to reflect the new module structure. - Removed deprecated `handler` and `transactions` modules to streamline the codebase. - Enhanced `TrailRecords` and `TrailCapabilities` with new functionalities for managing audit trail records and capabilities. --- audit-trail-rs/src/client/full_client.rs | 2 +- audit-trail-rs/src/client/read_only.rs | 2 +- audit-trail-rs/src/core/builder.rs | 2 +- .../capabilities.rs => capabilities/mod.rs} | 2 +- .../{transactions/create.rs => create/mod.rs} | 6 +- .../create.rs => create/operations.rs} | 9 +- audit-trail-rs/src/core/handler/mod.rs | 34 ----- audit-trail-rs/src/core/handler/records.rs | 133 ----------------- .../{handler/locking.rs => locking/mod.rs} | 2 +- .../{handler/metadata.rs => metadata/mod.rs} | 2 +- audit-trail-rs/src/core/mod.rs | 10 +- audit-trail-rs/src/core/operations/locking.rs | 54 ------- .../src/core/operations/metadata.rs | 30 ---- audit-trail-rs/src/core/operations/migrate.rs | 28 ---- audit-trail-rs/src/core/operations/records.rs | 104 ------------- .../record.rs => records/mod.rs} | 139 +++++++++++++++++- .../mod.rs => records/operations.rs} | 102 ++++++++++--- .../core/{handler/roles.rs => roles/mod.rs} | 2 +- .../core/{handler/handle.rs => trail/mod.rs} | 29 +++- audit-trail-rs/src/core/transactions/mod.rs | 8 - audit-trail-rs/src/core/types/mod.rs | 2 - audit-trail-rs/src/core/types/record.rs | 26 +++- .../src/core/types/record_correction.rs | 30 ---- 23 files changed, 285 insertions(+), 473 deletions(-) rename audit-trail-rs/src/core/{handler/capabilities.rs => capabilities/mod.rs} (96%) rename audit-trail-rs/src/core/{transactions/create.rs => create/mod.rs} (97%) rename audit-trail-rs/src/core/{operations/create.rs => create/operations.rs} (93%) delete mode 100644 audit-trail-rs/src/core/handler/mod.rs delete mode 100644 audit-trail-rs/src/core/handler/records.rs rename audit-trail-rs/src/core/{handler/locking.rs => locking/mod.rs} (94%) rename audit-trail-rs/src/core/{handler/metadata.rs => metadata/mod.rs} (93%) delete mode 100644 audit-trail-rs/src/core/operations/locking.rs delete mode 100644 audit-trail-rs/src/core/operations/metadata.rs delete mode 100644 audit-trail-rs/src/core/operations/migrate.rs delete mode 100644 audit-trail-rs/src/core/operations/records.rs rename audit-trail-rs/src/core/{transactions/record.rs => records/mod.rs} (50%) rename audit-trail-rs/src/core/{operations/mod.rs => records/operations.rs} (58%) rename audit-trail-rs/src/core/{handler/roles.rs => roles/mod.rs} (98%) rename audit-trail-rs/src/core/{handler/handle.rs => trail/mod.rs} (58%) delete mode 100644 audit-trail-rs/src/core/transactions/mod.rs delete mode 100644 audit-trail-rs/src/core/types/record_correction.rs diff --git a/audit-trail-rs/src/client/full_client.rs b/audit-trail-rs/src/client/full_client.rs index 8dc3434d..9c2932e9 100644 --- a/audit-trail-rs/src/client/full_client.rs +++ b/audit-trail-rs/src/client/full_client.rs @@ -9,7 +9,7 @@ use std::ops::Deref; use crate::client::read_only::AuditTrailClientReadOnly; use crate::core::builder::AuditTrailBuilder; -use crate::core::handler::{AuditTrailFull, AuditTrailHandle, AuditTrailReadOnly}; +use crate::core::trail::{AuditTrailFull, AuditTrailHandle, AuditTrailReadOnly}; use crate::error::Error; use async_trait::async_trait; use iota_interaction::types::base_types::ObjectID; diff --git a/audit-trail-rs/src/client/read_only.rs b/audit-trail-rs/src/client/read_only.rs index c1d2eab4..8cc4e5a7 100644 --- a/audit-trail-rs/src/client/read_only.rs +++ b/audit-trail-rs/src/client/read_only.rs @@ -18,7 +18,7 @@ use product_common::package_registry::Env; use serde::de::DeserializeOwned; use super::network_id; -use crate::core::handler::{AuditTrailHandle, AuditTrailReadOnly}; +use crate::core::trail::{AuditTrailHandle, AuditTrailReadOnly}; use crate::error::Error; use crate::iota_interaction_adapter::IotaClientAdapter; use crate::package; diff --git a/audit-trail-rs/src/core/builder.rs b/audit-trail-rs/src/core/builder.rs index b80f4185..771049b3 100644 --- a/audit-trail-rs/src/core/builder.rs +++ b/audit-trail-rs/src/core/builder.rs @@ -5,7 +5,7 @@ use product_common::transaction::transaction_builder::TransactionBuilder; -use super::transactions::CreateTrail; +use super::create::CreateTrail; use super::types::{Data, ImmutableMetadata, LockingConfig}; use crate::error::Error; diff --git a/audit-trail-rs/src/core/handler/capabilities.rs b/audit-trail-rs/src/core/capabilities/mod.rs similarity index 96% rename from audit-trail-rs/src/core/handler/capabilities.rs rename to audit-trail-rs/src/core/capabilities/mod.rs index 1d1fb6b1..2aa04ea6 100644 --- a/audit-trail-rs/src/core/handler/capabilities.rs +++ b/audit-trail-rs/src/core/capabilities/mod.rs @@ -3,7 +3,7 @@ use iota_interaction::types::base_types::ObjectID; -use super::AuditTrailFull; +use crate::core::trail::AuditTrailFull; use crate::error::Error; #[derive(Debug, Clone)] diff --git a/audit-trail-rs/src/core/transactions/create.rs b/audit-trail-rs/src/core/create/mod.rs similarity index 97% rename from audit-trail-rs/src/core/transactions/create.rs rename to audit-trail-rs/src/core/create/mod.rs index d7769f19..8cdd772c 100644 --- a/audit-trail-rs/src/core/transactions/create.rs +++ b/audit-trail-rs/src/core/create/mod.rs @@ -13,10 +13,12 @@ use product_common::transaction::transaction_builder::Transaction; use tokio::sync::OnceCell; use crate::core::builder::AuditTrailBuilder; -use crate::core::operations::AuditTrailImpl; use crate::core::types::{AuditTrailCreated, Capability, Event}; use crate::error::Error; +mod operations; +use self::operations::CreateOps; + /// Output of a create trail transaction. #[derive(Debug, Clone)] pub struct TrailCreated { @@ -55,7 +57,7 @@ impl CreateTrail { updatable_metadata, } = self.builder.clone(); - AuditTrailImpl::create_trail( + CreateOps::create_trail_tx( client.package_id(), initial_data, initial_record_metadata, diff --git a/audit-trail-rs/src/core/operations/create.rs b/audit-trail-rs/src/core/create/operations.rs similarity index 93% rename from audit-trail-rs/src/core/operations/create.rs rename to audit-trail-rs/src/core/create/operations.rs index f79d95da..ff1525ba 100644 --- a/audit-trail-rs/src/core/operations/create.rs +++ b/audit-trail-rs/src/core/create/operations.rs @@ -5,13 +5,14 @@ use iota_interaction::ident_str; use iota_interaction::types::base_types::ObjectID; use iota_interaction::types::transaction::ProgrammableTransaction; -use super::AuditTrailImpl; use crate::core::move_utils; use crate::core::types::{Data, ImmutableMetadata, LockingConfig}; use crate::error::Error; -impl AuditTrailImpl { - pub(crate) fn create_trail( +pub(super) struct CreateOps; + +impl CreateOps { + pub(super) fn create_trail_tx( package_id: ObjectID, initial_data: Option, initial_record_metadata: Option, @@ -40,7 +41,7 @@ impl AuditTrailImpl { package_id, ident_str!("main").into(), ident_str!("create").into(), - vec![], // TODO: Add type tag for initial data + vec![], vec![ initial_data_arg, initial_record_metadata, diff --git a/audit-trail-rs/src/core/handler/mod.rs b/audit-trail-rs/src/core/handler/mod.rs deleted file mode 100644 index 07a78360..00000000 --- a/audit-trail-rs/src/core/handler/mod.rs +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright 2020-2026 IOTA Stiftung -// SPDX-License-Identifier: Apache-2.0 - -pub mod capabilities; -pub mod handle; -pub mod locking; -pub mod metadata; -pub mod records; -pub mod roles; - -pub use capabilities::TrailCapabilities; -pub use handle::AuditTrailHandle; -use iota_interaction::OptionalSync; -use iota_interaction::types::transaction::ProgrammableTransaction; -pub use locking::TrailLocking; -pub use metadata::TrailMetadata; -use product_common::core_client::CoreClientReadOnly; -pub use records::TrailRecords; -pub use roles::TrailRoles; -use serde::de::DeserializeOwned; - -use crate::error::Error; - -/// Marker trait for read-only audit trail clients. -#[doc(hidden)] -#[async_trait::async_trait] -pub trait AuditTrailReadOnly: CoreClientReadOnly + OptionalSync { - async fn execute_read_only_transaction(&self, tx: ProgrammableTransaction) - -> Result; -} - -/// Marker trait for full (read-write) audit trail clients. -#[doc(hidden)] -pub trait AuditTrailFull: AuditTrailReadOnly {} diff --git a/audit-trail-rs/src/core/handler/records.rs b/audit-trail-rs/src/core/handler/records.rs deleted file mode 100644 index c8c39bc7..00000000 --- a/audit-trail-rs/src/core/handler/records.rs +++ /dev/null @@ -1,133 +0,0 @@ -// Copyright 2020-2026 IOTA Stiftung -// SPDX-License-Identifier: Apache-2.0 - -use iota_interaction::types::base_types::ObjectID; -use iota_interaction::{IotaKeySignature, OptionalSync}; -use product_common::core_client::CoreClient; -use product_common::transaction::transaction_builder::TransactionBuilder; -use secret_storage::Signer; -use serde::de::DeserializeOwned; - -use super::{AuditTrailFull, AuditTrailReadOnly}; -use crate::core::operations::AuditTrailImpl; -use crate::core::transactions::{AddRecord, DeleteRecord}; -use crate::core::types::{Data, Record}; -use crate::error::Error; - -#[derive(Debug, Clone)] -pub struct TrailRecords<'a, C, D = Data> { - pub(crate) client: &'a C, - pub(crate) trail_id: ObjectID, - pub(crate) _phantom: std::marker::PhantomData, -} - -impl<'a, C, D> TrailRecords<'a, C, D> { - pub(crate) fn new(client: &'a C, trail_id: ObjectID) -> Self { - Self { - client, - trail_id, - _phantom: std::marker::PhantomData, - } - } - - pub async fn get(&self, sequence_number: u64) -> Result, Error> - where - C: AuditTrailReadOnly, - D: DeserializeOwned, - { - let tx = AuditTrailImpl::get_record(self.client, self.trail_id, sequence_number).await?; - self.client.execute_read_only_transaction(tx).await - } - - pub async fn list(&self) -> Result>, Error> - where - C: AuditTrailReadOnly, - D: DeserializeOwned, - { - let first = self.first_sequence().await?; - let last = self.last_sequence().await?; - - let Some(first_seq) = first else { - return Ok(Vec::new()); - }; - let Some(last_seq) = last else { - return Ok(Vec::new()); - }; - - let mut records = Vec::new(); - for seq in first_seq..=last_seq { - if self.has_record(seq).await? { - records.push(self.get(seq).await?); - } - } - - Ok(records) - } - - pub fn add(&self, data: D, metadata: Option) -> Result, Error> - where - C: AuditTrailFull + CoreClient, - S: Signer + OptionalSync, - D: Into, - { - let owner = self.client.sender_address(); - Ok(TransactionBuilder::new(AddRecord::new( - self.trail_id, - owner, - data.into(), - metadata, - ))) - } - - pub fn delete(&self, sequence_number: u64) -> Result, Error> - where - C: AuditTrailFull + CoreClient, - S: Signer + OptionalSync, - { - let owner = self.client.sender_address(); - Ok(TransactionBuilder::new(DeleteRecord::new( - self.trail_id, - owner, - sequence_number, - ))) - } - - pub async fn correct(&self, _replaces: Vec, _data: D, _metadata: Option) -> Result<(), Error> - where - C: AuditTrailFull, - { - Err(Error::NotImplemented("TrailRecords::correct")) - } - - async fn has_record(&self, sequence_number: u64) -> Result - where - C: AuditTrailReadOnly, - { - let tx = AuditTrailImpl::has_record(self.client, self.trail_id, sequence_number).await?; - self.client.execute_read_only_transaction(tx).await - } - - async fn first_sequence(&self) -> Result, Error> - where - C: AuditTrailReadOnly, - { - let tx = AuditTrailImpl::first_sequence(self.client, self.trail_id).await?; - self.client.execute_read_only_transaction(tx).await - } - - async fn last_sequence(&self) -> Result, Error> - where - C: AuditTrailReadOnly, - { - let tx = AuditTrailImpl::last_sequence(self.client, self.trail_id).await?; - self.client.execute_read_only_transaction(tx).await - } - - pub async fn record_count(&self) -> Result - where - C: AuditTrailReadOnly, - { - let tx = AuditTrailImpl::record_count(self.client, self.trail_id).await?; - self.client.execute_read_only_transaction(tx).await - } -} diff --git a/audit-trail-rs/src/core/handler/locking.rs b/audit-trail-rs/src/core/locking/mod.rs similarity index 94% rename from audit-trail-rs/src/core/handler/locking.rs rename to audit-trail-rs/src/core/locking/mod.rs index 1435f64e..f3a9bd6b 100644 --- a/audit-trail-rs/src/core/handler/locking.rs +++ b/audit-trail-rs/src/core/locking/mod.rs @@ -3,7 +3,7 @@ use iota_interaction::types::base_types::ObjectID; -use super::{AuditTrailFull, AuditTrailReadOnly}; +use crate::core::trail::{AuditTrailFull, AuditTrailReadOnly}; use crate::core::types::{LockingConfig, LockingWindow}; use crate::error::Error; diff --git a/audit-trail-rs/src/core/handler/metadata.rs b/audit-trail-rs/src/core/metadata/mod.rs similarity index 93% rename from audit-trail-rs/src/core/handler/metadata.rs rename to audit-trail-rs/src/core/metadata/mod.rs index ab2864c9..f2e81b9b 100644 --- a/audit-trail-rs/src/core/handler/metadata.rs +++ b/audit-trail-rs/src/core/metadata/mod.rs @@ -3,7 +3,7 @@ use iota_interaction::types::base_types::ObjectID; -use super::AuditTrailFull; +use crate::core::trail::AuditTrailFull; use crate::error::Error; #[derive(Debug, Clone)] diff --git a/audit-trail-rs/src/core/mod.rs b/audit-trail-rs/src/core/mod.rs index 003ad1ff..f2ed1b63 100644 --- a/audit-trail-rs/src/core/mod.rs +++ b/audit-trail-rs/src/core/mod.rs @@ -4,8 +4,12 @@ //! Core types and builders for audit trails. pub mod builder; -pub mod handler; +pub mod capabilities; +pub mod create; +pub mod locking; +pub mod metadata; pub(crate) mod move_utils; -pub(crate) mod operations; -pub mod transactions; +pub mod records; +pub mod roles; +pub mod trail; pub mod types; diff --git a/audit-trail-rs/src/core/operations/locking.rs b/audit-trail-rs/src/core/operations/locking.rs deleted file mode 100644 index 760209ca..00000000 --- a/audit-trail-rs/src/core/operations/locking.rs +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright 2020-2026 IOTA Stiftung -// SPDX-License-Identifier: Apache-2.0 - -use iota_interaction::OptionalSync; -use iota_interaction::types::base_types::ObjectID; -use iota_interaction::types::transaction::ProgrammableTransaction; -use product_common::core_client::CoreClientReadOnly; - -use super::AuditTrailImpl; -use crate::core::move_utils; -use crate::core::types::LockingWindow; -use crate::error::Error; - -impl AuditTrailImpl { - pub(crate) async fn update_locking_config( - client: &C, - trail_id: ObjectID, - cap_id: ObjectID, - new_config: crate::core::types::LockingConfig, - ) -> Result - where - C: CoreClientReadOnly + OptionalSync, - { - Self::build_trail_transaction(client, trail_id, cap_id, "update_locking_config", |ptb| { - let config = move_utils::ptb_pure(ptb, "new_config", new_config)?; - let clock = move_utils::get_clock_ref(ptb); - Ok(vec![config, clock]) - }) - .await - } - - pub(crate) async fn update_locking_config_for_delete_record( - client: &C, - trail_id: ObjectID, - cap_id: ObjectID, - window: LockingWindow, - ) -> Result - where - C: CoreClientReadOnly + OptionalSync, - { - Self::build_trail_transaction( - client, - trail_id, - cap_id, - "update_locking_config_for_delete_record", - |ptb| { - let window = move_utils::ptb_pure(ptb, "new_delete_record_lock", window)?; - let clock = move_utils::get_clock_ref(ptb); - Ok(vec![window, clock]) - }, - ) - .await - } -} diff --git a/audit-trail-rs/src/core/operations/metadata.rs b/audit-trail-rs/src/core/operations/metadata.rs deleted file mode 100644 index 77f35c41..00000000 --- a/audit-trail-rs/src/core/operations/metadata.rs +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright 2020-2026 IOTA Stiftung -// SPDX-License-Identifier: Apache-2.0 - -use iota_interaction::OptionalSync; -use iota_interaction::types::base_types::ObjectID; -use iota_interaction::types::transaction::ProgrammableTransaction; -use product_common::core_client::CoreClientReadOnly; - -use super::AuditTrailImpl; -use crate::core::move_utils; -use crate::error::Error; - -impl AuditTrailImpl { - pub(crate) async fn update_metadata( - client: &C, - trail_id: ObjectID, - cap_id: ObjectID, - new_metadata: Option, - ) -> Result - where - C: CoreClientReadOnly + OptionalSync, - { - Self::build_trail_transaction(client, trail_id, cap_id, "update_metadata", |ptb| { - let meta = move_utils::ptb_pure(ptb, "new_metadata", new_metadata)?; - let clock = move_utils::get_clock_ref(ptb); - Ok(vec![meta, clock]) - }) - .await - } -} diff --git a/audit-trail-rs/src/core/operations/migrate.rs b/audit-trail-rs/src/core/operations/migrate.rs deleted file mode 100644 index 54be051f..00000000 --- a/audit-trail-rs/src/core/operations/migrate.rs +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright 2020-2026 IOTA Stiftung -// SPDX-License-Identifier: Apache-2.0 - -use iota_interaction::OptionalSync; -use iota_interaction::types::base_types::ObjectID; -use iota_interaction::types::transaction::ProgrammableTransaction; -use product_common::core_client::CoreClientReadOnly; - -use super::AuditTrailImpl; -use crate::core::move_utils; -use crate::error::Error; - -impl AuditTrailImpl { - pub(crate) async fn migrate( - client: &C, - trail_id: ObjectID, - cap_id: ObjectID, - ) -> Result - where - C: CoreClientReadOnly + OptionalSync, - { - Self::build_trail_transaction(client, trail_id, cap_id, "migrate", |ptb| { - let clock = move_utils::get_clock_ref(ptb); - Ok(vec![clock]) - }) - .await - } -} diff --git a/audit-trail-rs/src/core/operations/records.rs b/audit-trail-rs/src/core/operations/records.rs deleted file mode 100644 index 4e9f9dfe..00000000 --- a/audit-trail-rs/src/core/operations/records.rs +++ /dev/null @@ -1,104 +0,0 @@ -// Copyright 2020-2026 IOTA Stiftung -// SPDX-License-Identifier: Apache-2.0 - -use iota_interaction::OptionalSync; -use iota_interaction::types::base_types::{IotaAddress, ObjectID}; -use iota_interaction::types::transaction::ProgrammableTransaction; -use product_common::core_client::CoreClientReadOnly; - -use super::AuditTrailImpl; -use crate::core::move_utils; -use crate::core::types::Data; -use crate::error::Error; - -impl AuditTrailImpl { - pub(crate) async fn add_record( - client: &C, - trail_id: ObjectID, - owner: IotaAddress, - data: Data, - record_metadata: Option, - ) -> Result - where - C: CoreClientReadOnly + OptionalSync, - { - Self::build_trail_transaction_for_owner(client, trail_id, owner, "add_record", |ptb| { - let data_arg = match data { - Data::Bytes(bytes) => move_utils::ptb_pure(ptb, "stored_data", bytes)?, - Data::Text(text) => move_utils::ptb_pure(ptb, "stored_data", text)?, - }; - let metadata = move_utils::ptb_pure(ptb, "record_metadata", record_metadata)?; - let clock = move_utils::get_clock_ref(ptb); - Ok(vec![data_arg, metadata, clock]) - }) - .await - } - - pub(crate) async fn delete_record( - client: &C, - trail_id: ObjectID, - owner: IotaAddress, - sequence_number: u64, - ) -> Result - where - C: CoreClientReadOnly + OptionalSync, - { - Self::build_trail_transaction_for_owner(client, trail_id, owner, "delete_record", |ptb| { - let seq = move_utils::ptb_pure(ptb, "sequence_number", sequence_number)?; - let clock = move_utils::get_clock_ref(ptb); - Ok(vec![seq, clock]) - }) - .await - } - - pub(crate) async fn get_record( - client: &C, - trail_id: ObjectID, - sequence_number: u64, - ) -> Result - where - C: CoreClientReadOnly + OptionalSync, - { - Self::build_read_only_transaction(client, trail_id, "get_record", |ptb| { - let seq = move_utils::ptb_pure(ptb, "sequence_number", sequence_number)?; - Ok(vec![seq]) - }) - .await - } - - pub(crate) async fn has_record( - client: &C, - trail_id: ObjectID, - sequence_number: u64, - ) -> Result - where - C: CoreClientReadOnly + OptionalSync, - { - Self::build_read_only_transaction(client, trail_id, "has_record", |ptb| { - let seq = move_utils::ptb_pure(ptb, "sequence_number", sequence_number)?; - Ok(vec![seq]) - }) - .await - } - - pub(crate) async fn record_count(client: &C, trail_id: ObjectID) -> Result - where - C: CoreClientReadOnly + OptionalSync, - { - Self::build_read_only_transaction(client, trail_id, "record_count", |_| Ok(vec![])).await - } - - pub(crate) async fn first_sequence(client: &C, trail_id: ObjectID) -> Result - where - C: CoreClientReadOnly + OptionalSync, - { - Self::build_read_only_transaction(client, trail_id, "first_sequence", |_| Ok(vec![])).await - } - - pub(crate) async fn last_sequence(client: &C, trail_id: ObjectID) -> Result - where - C: CoreClientReadOnly + OptionalSync, - { - Self::build_read_only_transaction(client, trail_id, "last_sequence", |_| Ok(vec![])).await - } -} diff --git a/audit-trail-rs/src/core/transactions/record.rs b/audit-trail-rs/src/core/records/mod.rs similarity index 50% rename from audit-trail-rs/src/core/transactions/record.rs rename to audit-trail-rs/src/core/records/mod.rs index c6a53e8c..4e74bb3b 100644 --- a/audit-trail-rs/src/core/transactions/record.rs +++ b/audit-trail-rs/src/core/records/mod.rs @@ -1,19 +1,142 @@ -// Copyright 2020-2025 IOTA Stiftung +// Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 use async_trait::async_trait; -use iota_interaction::OptionalSync; use iota_interaction::rpc_types::{IotaTransactionBlockEffects, IotaTransactionBlockEvents}; use iota_interaction::types::base_types::{IotaAddress, ObjectID}; use iota_interaction::types::transaction::ProgrammableTransaction; -use product_common::core_client::CoreClientReadOnly; -use product_common::transaction::transaction_builder::Transaction; +use iota_interaction::{IotaKeySignature, OptionalSync}; +use product_common::core_client::{CoreClient, CoreClientReadOnly}; +use product_common::transaction::transaction_builder::{Transaction, TransactionBuilder}; +use secret_storage::Signer; +use serde::de::DeserializeOwned; use tokio::sync::OnceCell; -use crate::core::operations::AuditTrailImpl; -use crate::core::types::{Data, Event, RecordAdded, RecordDeleted}; +use crate::core::trail::{AuditTrailFull, AuditTrailReadOnly}; +use crate::core::types::{Data, Event, Record, RecordAdded, RecordDeleted}; use crate::error::Error; +mod operations; +use self::operations::RecordsOps; + +#[derive(Debug, Clone)] +pub struct TrailRecords<'a, C, D = Data> { + pub(crate) client: &'a C, + pub(crate) trail_id: ObjectID, + pub(crate) _phantom: std::marker::PhantomData, +} + +impl<'a, C, D> TrailRecords<'a, C, D> { + pub(crate) fn new(client: &'a C, trail_id: ObjectID) -> Self { + Self { + client, + trail_id, + _phantom: std::marker::PhantomData, + } + } + + pub async fn get(&self, sequence_number: u64) -> Result, Error> + where + C: AuditTrailReadOnly, + D: DeserializeOwned, + { + let tx = RecordsOps::get_record_tx(self.client, self.trail_id, sequence_number).await?; + self.client.execute_read_only_transaction(tx).await + } + + pub async fn list(&self) -> Result>, Error> + where + C: AuditTrailReadOnly, + D: DeserializeOwned, + { + let first = self.first_sequence().await?; + let last = self.last_sequence().await?; + + let Some(first_seq) = first else { + return Ok(Vec::new()); + }; + let Some(last_seq) = last else { + return Ok(Vec::new()); + }; + + let mut records = Vec::new(); + for seq in first_seq..=last_seq { + if self.has_record(seq).await? { + records.push(self.get(seq).await?); + } + } + + Ok(records) + } + + pub fn add(&self, data: D, metadata: Option) -> Result, Error> + where + C: AuditTrailFull + CoreClient, + S: Signer + OptionalSync, + D: Into, + { + let owner = self.client.sender_address(); + Ok(TransactionBuilder::new(AddRecord::new( + self.trail_id, + owner, + data.into(), + metadata, + ))) + } + + pub fn delete(&self, sequence_number: u64) -> Result, Error> + where + C: AuditTrailFull + CoreClient, + S: Signer + OptionalSync, + { + let owner = self.client.sender_address(); + Ok(TransactionBuilder::new(DeleteRecord::new( + self.trail_id, + owner, + sequence_number, + ))) + } + + pub async fn correct(&self, _replaces: Vec, _data: D, _metadata: Option) -> Result<(), Error> + where + C: AuditTrailFull, + { + Err(Error::NotImplemented("TrailRecords::correct")) + } + + async fn has_record(&self, sequence_number: u64) -> Result + where + C: AuditTrailReadOnly, + { + let tx = RecordsOps::has_record_tx(self.client, self.trail_id, sequence_number).await?; + self.client.execute_read_only_transaction(tx).await + } + + async fn first_sequence(&self) -> Result, Error> + where + C: AuditTrailReadOnly, + { + let tx = RecordsOps::first_sequence_tx(self.client, self.trail_id).await?; + self.client.execute_read_only_transaction(tx).await + } + + async fn last_sequence(&self) -> Result, Error> + where + C: AuditTrailReadOnly, + { + let tx = RecordsOps::last_sequence_tx(self.client, self.trail_id).await?; + self.client.execute_read_only_transaction(tx).await + } + + pub async fn record_count(&self) -> Result + where + C: AuditTrailReadOnly, + { + let tx = RecordsOps::record_count_tx(self.client, self.trail_id).await?; + self.client.execute_read_only_transaction(tx).await + } +} + #[derive(Debug, Clone)] pub struct AddRecord { pub trail_id: ObjectID, @@ -38,7 +161,7 @@ impl AddRecord { where C: CoreClientReadOnly + OptionalSync, { - AuditTrailImpl::add_record( + RecordsOps::add_record_tx( client, self.trail_id, self.owner, @@ -110,7 +233,7 @@ impl DeleteRecord { where C: CoreClientReadOnly + OptionalSync, { - AuditTrailImpl::delete_record(client, self.trail_id, self.owner, self.sequence_number).await + RecordsOps::delete_record_tx(client, self.trail_id, self.owner, self.sequence_number).await } } diff --git a/audit-trail-rs/src/core/operations/mod.rs b/audit-trail-rs/src/core/records/operations.rs similarity index 58% rename from audit-trail-rs/src/core/operations/mod.rs rename to audit-trail-rs/src/core/records/operations.rs index 505f97d6..22c89006 100644 --- a/audit-trail-rs/src/core/operations/mod.rs +++ b/audit-trail-rs/src/core/records/operations.rs @@ -1,8 +1,6 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -//! Audit trail operations for building Move transactions. - use std::str::FromStr; use iota_interaction::types::base_types::{IotaAddress, ObjectID, ObjectRef}; @@ -12,32 +10,100 @@ use iota_interaction::{OptionalSync, ident_str}; use product_common::core_client::CoreClientReadOnly; use crate::core::move_utils; -use crate::core::types::Capability; +use crate::core::types::{Capability, Data}; use crate::error::Error; -pub(crate) mod create; -pub(crate) mod locking; -pub(crate) mod metadata; -pub(crate) mod migrate; -pub(crate) mod records; +pub(super) struct RecordsOps; -#[derive(Debug, Clone)] -pub(crate) struct AuditTrailImpl; +impl RecordsOps { + pub(super) async fn add_record_tx( + client: &C, + trail_id: ObjectID, + owner: IotaAddress, + data: Data, + record_metadata: Option, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + Self::build_trail_transaction_for_owner(client, trail_id, owner, "add_record", |ptb| { + let data_arg = match data { + Data::Bytes(bytes) => move_utils::ptb_pure(ptb, "stored_data", bytes)?, + Data::Text(text) => move_utils::ptb_pure(ptb, "stored_data", text)?, + }; + let metadata = move_utils::ptb_pure(ptb, "record_metadata", record_metadata)?; + let clock = move_utils::get_clock_ref(ptb); + Ok(vec![data_arg, metadata, clock]) + }) + .await + } -impl AuditTrailImpl { - async fn build_trail_transaction( + pub(super) async fn delete_record_tx( client: &C, trail_id: ObjectID, - cap_id: ObjectID, - method: impl AsRef, - additional_args: F, + owner: IotaAddress, + sequence_number: u64, ) -> Result where - F: FnOnce(&mut ProgrammableTransactionBuilder) -> Result, Error>, C: CoreClientReadOnly + OptionalSync, { - let cap_ref = move_utils::get_object_ref_by_id(client, &cap_id).await?; - Self::build_trail_transaction_with_cap_ref(client, trail_id, cap_ref, method, additional_args).await + Self::build_trail_transaction_for_owner(client, trail_id, owner, "delete_record", |ptb| { + let seq = move_utils::ptb_pure(ptb, "sequence_number", sequence_number)?; + let clock = move_utils::get_clock_ref(ptb); + Ok(vec![seq, clock]) + }) + .await + } + + pub(super) async fn get_record_tx( + client: &C, + trail_id: ObjectID, + sequence_number: u64, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + Self::build_read_only_transaction(client, trail_id, "get_record", |ptb| { + let seq = move_utils::ptb_pure(ptb, "sequence_number", sequence_number)?; + Ok(vec![seq]) + }) + .await + } + + pub(super) async fn has_record_tx( + client: &C, + trail_id: ObjectID, + sequence_number: u64, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + Self::build_read_only_transaction(client, trail_id, "has_record", |ptb| { + let seq = move_utils::ptb_pure(ptb, "sequence_number", sequence_number)?; + Ok(vec![seq]) + }) + .await + } + + pub(super) async fn record_count_tx(client: &C, trail_id: ObjectID) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + Self::build_read_only_transaction(client, trail_id, "record_count", |_| Ok(vec![])).await + } + + pub(super) async fn first_sequence_tx(client: &C, trail_id: ObjectID) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + Self::build_read_only_transaction(client, trail_id, "first_sequence", |_| Ok(vec![])).await + } + + pub(super) async fn last_sequence_tx(client: &C, trail_id: ObjectID) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + Self::build_read_only_transaction(client, trail_id, "last_sequence", |_| Ok(vec![])).await } async fn build_trail_transaction_for_owner( diff --git a/audit-trail-rs/src/core/handler/roles.rs b/audit-trail-rs/src/core/roles/mod.rs similarity index 98% rename from audit-trail-rs/src/core/handler/roles.rs rename to audit-trail-rs/src/core/roles/mod.rs index ef781065..b95bbea3 100644 --- a/audit-trail-rs/src/core/handler/roles.rs +++ b/audit-trail-rs/src/core/roles/mod.rs @@ -3,7 +3,7 @@ use iota_interaction::types::base_types::ObjectID; -use super::AuditTrailFull; +use crate::core::trail::AuditTrailFull; use crate::core::types::PermissionSet; use crate::error::Error; diff --git a/audit-trail-rs/src/core/handler/handle.rs b/audit-trail-rs/src/core/trail/mod.rs similarity index 58% rename from audit-trail-rs/src/core/handler/handle.rs rename to audit-trail-rs/src/core/trail/mod.rs index 96308ce4..d6c4957e 100644 --- a/audit-trail-rs/src/core/handler/handle.rs +++ b/audit-trail-rs/src/core/trail/mod.rs @@ -1,16 +1,31 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -//! Typed handle wrappers bound to a specific trail id and client reference. - +use iota_interaction::OptionalSync; use iota_interaction::types::base_types::ObjectID; +use iota_interaction::types::transaction::ProgrammableTransaction; +use product_common::core_client::CoreClientReadOnly; +use serde::de::DeserializeOwned; -use super::capabilities::TrailCapabilities; -use super::locking::TrailLocking; -use super::metadata::TrailMetadata; -use super::records::TrailRecords; -use super::roles::TrailRoles; +use crate::core::capabilities::TrailCapabilities; +use crate::core::locking::TrailLocking; +use crate::core::metadata::TrailMetadata; +use crate::core::records::TrailRecords; +use crate::core::roles::TrailRoles; use crate::core::types::Data; +use crate::error::Error; + +/// Marker trait for read-only audit trail clients. +#[doc(hidden)] +#[async_trait::async_trait] +pub trait AuditTrailReadOnly: CoreClientReadOnly + OptionalSync { + async fn execute_read_only_transaction(&self, tx: ProgrammableTransaction) + -> Result; +} + +/// Marker trait for full (read-write) audit trail clients. +#[doc(hidden)] +pub trait AuditTrailFull: AuditTrailReadOnly {} /// A typed handle bound to a specific audit trail and client. #[derive(Debug, Clone)] diff --git a/audit-trail-rs/src/core/transactions/mod.rs b/audit-trail-rs/src/core/transactions/mod.rs deleted file mode 100644 index f8360f90..00000000 --- a/audit-trail-rs/src/core/transactions/mod.rs +++ /dev/null @@ -1,8 +0,0 @@ -// Copyright 2020-2025 IOTA Stiftung -// SPDX-License-Identifier: Apache-2.0 - -pub mod create; -pub mod record; - -pub use create::*; -pub use record::*; diff --git a/audit-trail-rs/src/core/types/mod.rs b/audit-trail-rs/src/core/types/mod.rs index 472e3ea3..180a606f 100644 --- a/audit-trail-rs/src/core/types/mod.rs +++ b/audit-trail-rs/src/core/types/mod.rs @@ -10,7 +10,6 @@ pub mod locking; pub mod metadata; pub mod permission; pub mod record; -pub mod record_correction; pub mod role_map; pub use audit_trail::*; @@ -20,5 +19,4 @@ pub use locking::*; pub use metadata::*; pub use permission::*; pub use record::*; -pub use record_correction::*; pub use role_map::*; diff --git a/audit-trail-rs/src/core/types/record.rs b/audit-trail-rs/src/core/types/record.rs index de854bae..a3a62593 100644 --- a/audit-trail-rs/src/core/types/record.rs +++ b/audit-trail-rs/src/core/types/record.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Deserializer, Serialize}; use crate::error::Error; -use super::record_correction::RecordCorrection; +use std::collections::HashSet; /// A single record in the audit trail. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -22,6 +22,30 @@ pub struct Record { pub correction: RecordCorrection, } +/// Bidirectional correction tracking for audit records. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct RecordCorrection { + pub replaces: HashSet, + pub is_replaced_by: Option, +} + +impl RecordCorrection { + pub fn with_replaces(replaces: HashSet) -> Self { + Self { + replaces, + is_replaced_by: None, + } + } + + pub fn is_correction(&self) -> bool { + !self.replaces.is_empty() + } + + pub fn is_replaced(&self) -> bool { + self.is_replaced_by.is_some() + } +} + /// Supported record data types. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub enum Data { diff --git a/audit-trail-rs/src/core/types/record_correction.rs b/audit-trail-rs/src/core/types/record_correction.rs deleted file mode 100644 index 640b511f..00000000 --- a/audit-trail-rs/src/core/types/record_correction.rs +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright 2020-2026 IOTA Stiftung -// SPDX-License-Identifier: Apache-2.0 - -use std::collections::HashSet; - -use serde::{Deserialize, Serialize}; - -/// Bidirectional correction tracking for audit records. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] -pub struct RecordCorrection { - pub replaces: HashSet, - pub is_replaced_by: Option, -} - -impl RecordCorrection { - pub fn with_replaces(replaces: HashSet) -> Self { - Self { - replaces, - is_replaced_by: None, - } - } - - pub fn is_correction(&self) -> bool { - !self.replaces.is_empty() - } - - pub fn is_replaced(&self) -> bool { - self.is_replaced_by.is_some() - } -} From c04039fd8620a9d343f6f277f9a96614dbd6036c Mon Sep 17 00:00:00 2001 From: Yasir Date: Mon, 9 Feb 2026 14:54:54 +0300 Subject: [PATCH 052/189] refactor: Update import paths and introduce new trail module - Updated import for `CreateTrail` to reflect its new location in the `create` module. - Added a new `trail.rs` module containing traits and structures for managing audit trails, including `AuditTrailReadOnly`, `AuditTrailFull`, and `AuditTrailHandle`. - Enhanced organization of audit trail components for improved modularity and maintainability. --- audit-trail-rs/src/core/builder.rs | 2 +- audit-trail-rs/src/core/{trail/mod.rs => trail.rs} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename audit-trail-rs/src/core/{trail/mod.rs => trail.rs} (100%) diff --git a/audit-trail-rs/src/core/builder.rs b/audit-trail-rs/src/core/builder.rs index 771049b3..acfc2fa2 100644 --- a/audit-trail-rs/src/core/builder.rs +++ b/audit-trail-rs/src/core/builder.rs @@ -5,8 +5,8 @@ use product_common::transaction::transaction_builder::TransactionBuilder; -use super::create::CreateTrail; use super::types::{Data, ImmutableMetadata, LockingConfig}; +use crate::core::create::CreateTrail; use crate::error::Error; /// Builder for creating an audit trail. diff --git a/audit-trail-rs/src/core/trail/mod.rs b/audit-trail-rs/src/core/trail.rs similarity index 100% rename from audit-trail-rs/src/core/trail/mod.rs rename to audit-trail-rs/src/core/trail.rs From 464378a74ba1d7f69716b79948b78862513667e1 Mon Sep 17 00:00:00 2001 From: Yasir Date: Mon, 9 Feb 2026 16:32:24 +0300 Subject: [PATCH 053/189] refactor: Consolidate utility functions and update audit trail structures for improved clarity and performance --- audit-trail-move/sources/audit_trail.move | 5 +- audit-trail-rs/src/core/create/mod.rs | 30 ++-- audit-trail-rs/src/core/create/operations.rs | 18 +- audit-trail-rs/src/core/mod.rs | 2 +- audit-trail-rs/src/core/records/mod.rs | 169 +++++++++++++++++- audit-trail-rs/src/core/records/operations.rs | 28 +-- audit-trail-rs/src/core/types/audit_trail.rs | 11 +- audit-trail-rs/src/core/types/locking.rs | 36 ++-- audit-trail-rs/src/core/types/permission.rs | 62 ++++--- audit-trail-rs/src/core/types/record.rs | 10 +- audit-trail-rs/src/core/types/role_map.rs | 13 +- .../src/core/{move_utils.rs => utils.rs} | 65 ++++++- audit-trail-rs/tests/e2e/records.rs | 4 - 13 files changed, 343 insertions(+), 110 deletions(-) rename audit-trail-rs/src/core/{move_utils.rs => utils.rs} (67%) diff --git a/audit-trail-move/sources/audit_trail.move b/audit-trail-move/sources/audit_trail.move index 7f492dba..76c1a851 100644 --- a/audit-trail-move/sources/audit_trail.move +++ b/audit-trail-move/sources/audit_trail.move @@ -11,7 +11,7 @@ use audit_trail::{ locking::{Self, LockingConfig, LockingWindow, set_delete_record_lock}, permission::{Self, Permission}, record::{Self, Record}, - record_correction, + record_correction }; use iota::{clock::{Self, Clock}, event, linked_table::{Self, LinkedTable}}; use std::string::String; @@ -78,7 +78,6 @@ public struct AuditTrailCreated has copy, drop { trail_id: ID, creator: address, timestamp: u64, - has_initial_record: bool, } /// Emitted when the audit trail is deleted @@ -146,7 +145,6 @@ public fun create( let mut records = linked_table::new>(ctx); let mut sequence_number = 0; - let has_initial_record = initial_data.is_some(); if (initial_data.is_some()) { let record = record::new( @@ -210,7 +208,6 @@ public fun create( trail_id, creator, timestamp, - has_initial_record, }); (admin_cap, trail_id) diff --git a/audit-trail-rs/src/core/create/mod.rs b/audit-trail-rs/src/core/create/mod.rs index 8cdd772c..658d8f12 100644 --- a/audit-trail-rs/src/core/create/mod.rs +++ b/audit-trail-rs/src/core/create/mod.rs @@ -8,12 +8,13 @@ use iota_interaction::rpc_types::{ }; use iota_interaction::types::base_types::ObjectID; use iota_interaction::types::transaction::ProgrammableTransaction; +use iota_sdk::types::base_types::IotaAddress; use product_common::core_client::CoreClientReadOnly; use product_common::transaction::transaction_builder::Transaction; use tokio::sync::OnceCell; use crate::core::builder::AuditTrailBuilder; -use crate::core::types::{AuditTrailCreated, Capability, Event}; +use crate::core::types::{AuditTrailCreated, Event, OnChainAuditTrail}; use crate::error::Error; mod operations; @@ -23,10 +24,20 @@ use self::operations::CreateOps; #[derive(Debug, Clone)] pub struct TrailCreated { pub trail_id: ObjectID, - pub admin_capability_id: Option, - pub creator: iota_interaction::types::base_types::IotaAddress, + pub creator: IotaAddress, pub timestamp: u64, - pub has_initial_record: bool, +} + +impl TrailCreated { + pub async fn load_on_chain(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + client + .get_object_by_id(self.trail_id) + .await + .map_err(|e| Error::UnexpectedApiResponse(format!("failed to load trail {}; {e}", self.trail_id))) + } } /// A transaction that creates a new audit trail. @@ -96,21 +107,10 @@ impl Transaction for CreateTrail { .find_map(|data| serde_json::from_value::>(data.parsed_json.clone()).ok()) .ok_or_else(|| Error::UnexpectedApiResponse("AuditTrailCreated event not found".to_string()))?; - let mut admin_capability_id = None; - for created in effects.created() { - let object_id = created.object_id(); - if let Ok(capability) = client.get_object_by_id::(object_id).await { - admin_capability_id = Some(*capability.id.object_id()); - break; - } - } - Ok(TrailCreated { trail_id: event.data.trail_id, - admin_capability_id, creator: event.data.creator, timestamp: event.data.timestamp, - has_initial_record: event.data.has_initial_record, }) } diff --git a/audit-trail-rs/src/core/create/operations.rs b/audit-trail-rs/src/core/create/operations.rs index ff1525ba..e522d0a4 100644 --- a/audit-trail-rs/src/core/create/operations.rs +++ b/audit-trail-rs/src/core/create/operations.rs @@ -5,7 +5,7 @@ use iota_interaction::ident_str; use iota_interaction::types::base_types::ObjectID; use iota_interaction::types::transaction::ProgrammableTransaction; -use crate::core::move_utils; +use crate::core::utils; use crate::core::types::{Data, ImmutableMetadata, LockingConfig}; use crate::error::Error; @@ -23,19 +23,19 @@ impl CreateOps { let mut ptb = iota_interaction::types::programmable_transaction_builder::ProgrammableTransactionBuilder::new(); let initial_data_arg = match initial_data { - Some(data) => move_utils::ptb_pure(&mut ptb, "initial_data", Some(data))?, - None => move_utils::ptb_pure::>(&mut ptb, "initial_data", None)?, + Some(data) => utils::ptb_pure(&mut ptb, "initial_data", Some(data))?, + None => utils::ptb_pure::>(&mut ptb, "initial_data", None)?, }; let initial_record_metadata = - move_utils::ptb_pure(&mut ptb, "initial_record_metadata", initial_record_metadata)?; - let locking_config = move_utils::ptb_pure(&mut ptb, "locking_config", locking_config)?; + utils::ptb_pure(&mut ptb, "initial_record_metadata", initial_record_metadata)?; + let locking_config = utils::ptb_pure(&mut ptb, "locking_config", locking_config)?; let trail_metadata = match trail_metadata { - Some(metadata) => move_utils::ptb_pure(&mut ptb, "trail_metadata", Some(metadata))?, - None => move_utils::ptb_pure::>(&mut ptb, "trail_metadata", None)?, + Some(metadata) => utils::ptb_pure(&mut ptb, "trail_metadata", Some(metadata))?, + None => utils::ptb_pure::>(&mut ptb, "trail_metadata", None)?, }; - let updatable_metadata = move_utils::ptb_pure(&mut ptb, "updatable_metadata", updatable_metadata)?; - let clock = move_utils::get_clock_ref(&mut ptb); + let updatable_metadata = utils::ptb_pure(&mut ptb, "updatable_metadata", updatable_metadata)?; + let clock = utils::get_clock_ref(&mut ptb); ptb.programmable_move_call( package_id, diff --git a/audit-trail-rs/src/core/mod.rs b/audit-trail-rs/src/core/mod.rs index f2ed1b63..1f89684e 100644 --- a/audit-trail-rs/src/core/mod.rs +++ b/audit-trail-rs/src/core/mod.rs @@ -8,7 +8,7 @@ pub mod capabilities; pub mod create; pub mod locking; pub mod metadata; -pub(crate) mod move_utils; +pub(crate) mod utils; pub mod records; pub mod roles; pub mod trail; diff --git a/audit-trail-rs/src/core/records/mod.rs b/audit-trail-rs/src/core/records/mod.rs index 4e74bb3b..f8944451 100644 --- a/audit-trail-rs/src/core/records/mod.rs +++ b/audit-trail-rs/src/core/records/mod.rs @@ -2,18 +2,24 @@ // SPDX-License-Identifier: Apache-2.0 use async_trait::async_trait; +use iota_interaction::move_types::annotated_value::MoveValue; +use iota_interaction::rpc_types::IotaMoveValue; use iota_interaction::rpc_types::{IotaTransactionBlockEffects, IotaTransactionBlockEvents}; +use iota_interaction::types::TypeTag; use iota_interaction::types::base_types::{IotaAddress, ObjectID}; +use iota_interaction::types::collection_types::{LinkedTable, LinkedTableNode}; +use iota_interaction::types::dynamic_field::DynamicFieldName; use iota_interaction::types::transaction::ProgrammableTransaction; -use iota_interaction::{IotaKeySignature, OptionalSync}; +use iota_interaction::{IotaClientTrait, IotaKeySignature, OptionalSync}; use product_common::core_client::{CoreClient, CoreClientReadOnly}; use product_common::transaction::transaction_builder::{Transaction, TransactionBuilder}; use secret_storage::Signer; -use serde::de::DeserializeOwned; +use serde::{Deserialize, de::DeserializeOwned}; +use std::collections::HashMap; use tokio::sync::OnceCell; use crate::core::trail::{AuditTrailFull, AuditTrailReadOnly}; -use crate::core::types::{Data, Event, Record, RecordAdded, RecordDeleted}; +use crate::core::types::{Data, Event, OnChainAuditTrail, PaginatedRecord, Record, RecordAdded, RecordDeleted}; use crate::error::Error; mod operations; @@ -135,6 +141,61 @@ impl<'a, C, D> TrailRecords<'a, C, D> { let tx = RecordsOps::record_count_tx(self.client, self.trail_id).await?; self.client.execute_read_only_transaction(tx).await } + + /// List all linked-table records into a [`HashMap`]. + /// + /// This traverses the full on-chain linked table and can be expensive for large trails. + pub async fn list_all(&self) -> Result>, Error> + where + C: AuditTrailReadOnly, + D: DeserializeOwned, + { + let records_table = self.load_records_table().await?; + list_linked_table::<_, Record>(self.client, &records_table, None).await + } + + /// List all records with a hard cap to protect against expensive traversals. + pub async fn list_with_limit(&self, max_entries: usize) -> Result>, Error> + where + C: AuditTrailReadOnly, + D: DeserializeOwned, + { + let records_table = self.load_records_table().await?; + list_linked_table::<_, Record>(self.client, &records_table, Some(max_entries)).await + } + + /// List one page of linked-table records starting from `cursor`. + /// + /// Pass `None` for the first page; use `next_cursor` for subsequent pages. + pub async fn list_page(&self, cursor: Option, limit: usize) -> Result, Error> + where + C: AuditTrailReadOnly, + D: DeserializeOwned, + { + let records_table = self.load_records_table().await?; + let (records, next_cursor) = + list_linked_table_page::<_, Record>(self.client, &records_table, cursor, limit).await?; + + Ok(PaginatedRecord { + has_next_page: next_cursor.is_some(), + next_cursor, + records, + }) + } + + async fn load_records_table(&self) -> Result, Error> + where + C: AuditTrailReadOnly, + { + let on_chain_trail: OnChainAuditTrail = self.client.get_object_by_id(self.trail_id).await.map_err(|err| { + Error::UnexpectedApiResponse(format!( + "failed to load on-chain trail {} for hydration; {err}", + self.trail_id + )) + })?; + + Ok(on_chain_trail.records) + } } #[derive(Debug, Clone)] @@ -277,3 +338,105 @@ impl Transaction for DeleteRecord { unreachable!() } } + +async fn list_linked_table_page( + client: &C, + table: &LinkedTable, + start_key: Option, + limit: usize, +) -> Result<(HashMap, Option), Error> +where + C: CoreClientReadOnly + OptionalSync, + V: DeserializeOwned, +{ + if limit == 0 { + return Ok((HashMap::new(), start_key.or(table.head))); + } + + let mut cursor = start_key.or(table.head); + let mut items = HashMap::new(); + + for _ in 0..limit { + let Some(key) = cursor else { break }; + + if items.contains_key(&key) { + return Err(Error::UnexpectedApiResponse(format!( + "cycle detected while traversing linked-table {table_id}; repeated key {key}", + table_id = table.id + ))); + } + + let name = DynamicFieldName { + type_: TypeTag::U64, + value: IotaMoveValue::from(MoveValue::U64(key)).to_json_value(), + }; + + let response = client + .client_adapter() + .read_api() + .get_dynamic_field_object(table.id, name) + .await + .map_err(|err| Error::RpcError(err.to_string()))?; + + let node_object_id = response + .data + .ok_or_else(|| { + Error::UnexpectedApiResponse(format!( + "missing dynamic-field object for linked-table id {} and key {key}", + table.id + )) + })? + .object_id; + + #[derive(Debug, Deserialize)] + struct DynamicFieldObject { + value: LinkedTableNode, + } + + let node: DynamicFieldObject = client.get_object_by_id(node_object_id).await.map_err(|err| { + Error::UnexpectedApiResponse(format!("failed to decode linked-table node {node_object_id}; {err}")) + })?; + + let node = node.value; + cursor = node.next; + items.insert(key, node.value); + } + + Ok((items, cursor)) +} + +async fn list_linked_table( + client: &C, + table: &LinkedTable, + max_entries: Option, +) -> Result, Error> +where + C: CoreClientReadOnly + OptionalSync, + V: DeserializeOwned, +{ + let expected = table.size as usize; + let cap = max_entries.unwrap_or(expected); + + if expected > cap { + return Err(Error::InvalidArgument(format!( + "linked-table size {expected} exceeds max_entries {cap}" + ))); + } + + let (entries, next_key) = list_linked_table_page(client, table, None, expected).await?; + + if entries.len() != expected { + return Err(Error::UnexpectedApiResponse(format!( + "linked-table traversal mismatch; expected {expected} entries, got {}", + entries.len() + ))); + } + + if next_key.is_some() { + return Err(Error::UnexpectedApiResponse(format!( + "linked-table traversal has extra entries beyond declared size {expected}" + ))); + } + + Ok(entries) +} diff --git a/audit-trail-rs/src/core/records/operations.rs b/audit-trail-rs/src/core/records/operations.rs index 22c89006..62cc0bbf 100644 --- a/audit-trail-rs/src/core/records/operations.rs +++ b/audit-trail-rs/src/core/records/operations.rs @@ -9,7 +9,7 @@ use iota_interaction::types::transaction::{Argument, ObjectArg, ProgrammableTran use iota_interaction::{OptionalSync, ident_str}; use product_common::core_client::CoreClientReadOnly; -use crate::core::move_utils; +use crate::core::utils; use crate::core::types::{Capability, Data}; use crate::error::Error; @@ -28,11 +28,11 @@ impl RecordsOps { { Self::build_trail_transaction_for_owner(client, trail_id, owner, "add_record", |ptb| { let data_arg = match data { - Data::Bytes(bytes) => move_utils::ptb_pure(ptb, "stored_data", bytes)?, - Data::Text(text) => move_utils::ptb_pure(ptb, "stored_data", text)?, + Data::Bytes(bytes) => utils::ptb_pure(ptb, "stored_data", bytes)?, + Data::Text(text) => utils::ptb_pure(ptb, "stored_data", text)?, }; - let metadata = move_utils::ptb_pure(ptb, "record_metadata", record_metadata)?; - let clock = move_utils::get_clock_ref(ptb); + let metadata = utils::ptb_pure(ptb, "record_metadata", record_metadata)?; + let clock = utils::get_clock_ref(ptb); Ok(vec![data_arg, metadata, clock]) }) .await @@ -48,8 +48,8 @@ impl RecordsOps { C: CoreClientReadOnly + OptionalSync, { Self::build_trail_transaction_for_owner(client, trail_id, owner, "delete_record", |ptb| { - let seq = move_utils::ptb_pure(ptb, "sequence_number", sequence_number)?; - let clock = move_utils::get_clock_ref(ptb); + let seq = utils::ptb_pure(ptb, "sequence_number", sequence_number)?; + let clock = utils::get_clock_ref(ptb); Ok(vec![seq, clock]) }) .await @@ -64,7 +64,7 @@ impl RecordsOps { C: CoreClientReadOnly + OptionalSync, { Self::build_read_only_transaction(client, trail_id, "get_record", |ptb| { - let seq = move_utils::ptb_pure(ptb, "sequence_number", sequence_number)?; + let seq = utils::ptb_pure(ptb, "sequence_number", sequence_number)?; Ok(vec![seq]) }) .await @@ -79,7 +79,7 @@ impl RecordsOps { C: CoreClientReadOnly + OptionalSync, { Self::build_read_only_transaction(client, trail_id, "has_record", |ptb| { - let seq = move_utils::ptb_pure(ptb, "sequence_number", sequence_number)?; + let seq = utils::ptb_pure(ptb, "sequence_number", sequence_number)?; Ok(vec![seq]) }) .await @@ -134,8 +134,8 @@ impl RecordsOps { { let mut ptb = ProgrammableTransactionBuilder::new(); - let tag = vec![move_utils::get_type_tag(client, &trail_id).await?]; - let trail_arg = move_utils::get_shared_object_arg(client, &trail_id, true).await?; + let tag = vec![utils::get_type_tag(client, &trail_id).await?]; + let trail_arg = utils::get_shared_object_arg(client, &trail_id, true).await?; let mut args = vec![ ptb.obj(trail_arg) @@ -167,7 +167,7 @@ impl RecordsOps { })?; let object_id = *cap.id.object_id(); - move_utils::get_object_ref_by_id(client, &object_id).await + utils::get_object_ref_by_id(client, &object_id).await } async fn build_read_only_transaction( @@ -182,8 +182,8 @@ impl RecordsOps { { let mut ptb = ProgrammableTransactionBuilder::new(); - let tag = vec![move_utils::get_type_tag(client, &trail_id).await?]; - let trail_arg = move_utils::get_shared_object_arg(client, &trail_id, false).await?; + let tag = vec![utils::get_type_tag(client, &trail_id).await?]; + let trail_arg = utils::get_shared_object_arg(client, &trail_id, false).await?; let mut args = vec![ ptb.obj(trail_arg) diff --git a/audit-trail-rs/src/core/types/audit_trail.rs b/audit-trail-rs/src/core/types/audit_trail.rs index b77b205f..1c4058b1 100644 --- a/audit-trail-rs/src/core/types/audit_trail.rs +++ b/audit-trail-rs/src/core/types/audit_trail.rs @@ -1,7 +1,9 @@ +use std::collections::HashMap; + // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 - use iota_interaction::types::base_types::IotaAddress; +use iota_interaction::types::collection_types::LinkedTable; use iota_interaction::types::id::UID; use serde::{Deserialize, Serialize}; @@ -9,23 +11,20 @@ use crate::core::types::Data; use super::locking::LockingConfig; use super::metadata::ImmutableMetadata; -use super::permission::Permission; use super::record::Record; use super::role_map::RoleMap; /// An audit trail stored on-chain. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct AuditTrail { +pub struct OnChainAuditTrail { pub id: UID, pub creator: IotaAddress, pub created_at: u64, pub sequence_number: u64, - pub records: Vec>, + pub records: LinkedTable, pub locking_config: LockingConfig, pub roles: RoleMap, pub immutable_metadata: Option, pub updatable_metadata: Option, pub version: u64, - #[serde(skip)] - pub _phantom: std::marker::PhantomData, } diff --git a/audit-trail-rs/src/core/types/locking.rs b/audit-trail-rs/src/core/types/locking.rs index c7711856..ab51682c 100644 --- a/audit-trail-rs/src/core/types/locking.rs +++ b/audit-trail-rs/src/core/types/locking.rs @@ -3,58 +3,58 @@ use serde::{Deserialize, Serialize}; -/// Defines a locking window (time or count based). +/// Locking configuration for the audit trail. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] -pub struct LockingWindow { - pub time_window_seconds: Option, - pub count_window: Option, +pub struct LockingConfig { + pub delete_record_lock: LockingWindow, } -impl LockingWindow { +impl LockingConfig { pub fn none() -> Self { Self { - time_window_seconds: None, - count_window: None, + delete_record_lock: LockingWindow::none(), } } pub fn time_based(seconds: u64) -> Self { Self { - time_window_seconds: Some(seconds), - count_window: None, + delete_record_lock: LockingWindow::time_based(seconds), } } pub fn count_based(count: u64) -> Self { Self { - time_window_seconds: None, - count_window: Some(count), + delete_record_lock: LockingWindow::count_based(count), } } } -/// Locking configuration for the audit trail. +/// Defines a locking window (time or count based). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] -pub struct LockingConfig { - pub delete_record_lock: LockingWindow, +pub struct LockingWindow { + pub time_window_seconds: Option, + pub count_window: Option, } -impl LockingConfig { +impl LockingWindow { pub fn none() -> Self { Self { - delete_record_lock: LockingWindow::none(), + time_window_seconds: None, + count_window: None, } } pub fn time_based(seconds: u64) -> Self { Self { - delete_record_lock: LockingWindow::time_based(seconds), + time_window_seconds: Some(seconds), + count_window: None, } } pub fn count_based(count: u64) -> Self { Self { - delete_record_lock: LockingWindow::count_based(count), + time_window_seconds: None, + count_window: Some(count), } } } diff --git a/audit-trail-rs/src/core/types/permission.rs b/audit-trail-rs/src/core/types/permission.rs index bd2c8b82..bb9b4a29 100644 --- a/audit-trail-rs/src/core/types/permission.rs +++ b/audit-trail-rs/src/core/types/permission.rs @@ -33,50 +33,54 @@ impl PermissionSet { Self { permissions: vec![] } } - pub fn from_vec(permissions: Vec) -> Self { - Self { permissions } - } - pub fn admin_permissions() -> Self { - Self::from_vec(vec![ - Permission::DeleteAuditTrail, - Permission::AddCapabilities, - Permission::RevokeCapabilities, - Permission::AddRoles, - Permission::UpdateRoles, - Permission::DeleteRoles, - ]) + Self { + permissions: vec![ + Permission::DeleteAuditTrail, + Permission::AddCapabilities, + Permission::RevokeCapabilities, + Permission::AddRoles, + Permission::UpdateRoles, + Permission::DeleteRoles, + ], + } } pub fn record_admin_permissions() -> Self { - Self::from_vec(vec![ - Permission::AddRecord, - Permission::DeleteRecord, - Permission::CorrectRecord, - ]) + Self { + permissions: vec![ + Permission::AddRecord, + Permission::DeleteRecord, + Permission::CorrectRecord, + ], + } } pub fn locking_admin_permissions() -> Self { - Self::from_vec(vec![ - Permission::UpdateLockingConfig, - Permission::UpdateLockingConfigForDeleteTrail, - Permission::UpdateLockingConfigForDeleteRecord, - ]) + Self { + permissions: vec![ + Permission::UpdateLockingConfig, + Permission::UpdateLockingConfigForDeleteTrail, + Permission::UpdateLockingConfigForDeleteRecord, + ], + } } pub fn role_admin_permissions() -> Self { - Self::from_vec(vec![ - Permission::AddRoles, - Permission::UpdateRoles, - Permission::DeleteRoles, - ]) + Self { + permissions: vec![Permission::AddRoles, Permission::UpdateRoles, Permission::DeleteRoles], + } } pub fn cap_admin_permissions() -> Self { - Self::from_vec(vec![Permission::AddCapabilities, Permission::RevokeCapabilities]) + Self { + permissions: vec![Permission::AddCapabilities, Permission::RevokeCapabilities], + } } pub fn metadata_admin_permissions() -> Self { - Self::from_vec(vec![Permission::UpdateMetadata, Permission::DeleteMetadata]) + Self { + permissions: vec![Permission::UpdateMetadata, Permission::DeleteMetadata], + } } } diff --git a/audit-trail-rs/src/core/types/record.rs b/audit-trail-rs/src/core/types/record.rs index a3a62593..b90af64e 100644 --- a/audit-trail-rs/src/core/types/record.rs +++ b/audit-trail-rs/src/core/types/record.rs @@ -9,7 +9,15 @@ use serde::{Deserialize, Deserializer, Serialize}; use crate::error::Error; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; + +/// Page of records loaded through linked-table traversal. +#[derive(Debug, Clone)] +pub struct PaginatedRecord { + pub records: HashMap>, + pub next_cursor: Option, + pub has_next_page: bool, +} /// A single record in the audit trail. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/audit-trail-rs/src/core/types/role_map.rs b/audit-trail-rs/src/core/types/role_map.rs index 28a12937..6c32315a 100644 --- a/audit-trail-rs/src/core/types/role_map.rs +++ b/audit-trail-rs/src/core/types/role_map.rs @@ -1,9 +1,14 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +use std::collections::{HashMap, HashSet}; + use iota_interaction::types::base_types::ObjectID; use serde::{Deserialize, Serialize}; +use crate::core::utils::deserialize_vec_map; +use crate::core::utils::deserialize_vec_set; + use super::permission::Permission; /// Defines the permissions required to administer roles in this RoleMap. @@ -27,9 +32,11 @@ pub struct CapabilityAdminPermissions { /// collections as Rust vectors for convenience. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RoleMap { - pub security_vault_id: ObjectID, - pub roles: Vec<(String, Vec)>, - pub issued_capabilities: Vec, + pub target_key: ObjectID, + #[serde(deserialize_with = "deserialize_vec_map")] + pub roles: HashMap>, + #[serde(deserialize_with = "deserialize_vec_set")] + pub issued_capabilities: HashSet, pub role_admin_permissions: RoleAdminPermissions, pub capability_admin_permissions: CapabilityAdminPermissions, } diff --git a/audit-trail-rs/src/core/move_utils.rs b/audit-trail-rs/src/core/utils.rs similarity index 67% rename from audit-trail-rs/src/core/move_utils.rs rename to audit-trail-rs/src/core/utils.rs index 35ee4172..91111270 100644 --- a/audit-trail-rs/src/core/move_utils.rs +++ b/audit-trail-rs/src/core/utils.rs @@ -1,19 +1,26 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +use std::collections::{HashMap, HashSet}; use std::str::FromStr; +use crate::error::Error; use iota_interaction::rpc_types::IotaObjectDataOptions; +use iota_interaction::types::MOVE_STDLIB_PACKAGE_ID; +use iota_interaction::types::base_types::STD_OPTION_MODULE_NAME; use iota_interaction::types::base_types::{ObjectID, ObjectRef}; +use iota_interaction::types::collection_types::{VecMap, VecSet}; use iota_interaction::types::object::Owner; use iota_interaction::types::programmable_transaction_builder::ProgrammableTransactionBuilder as Ptb; +use iota_interaction::types::programmable_transaction_builder::ProgrammableTransactionBuilder; use iota_interaction::types::transaction::{Argument, ObjectArg}; use iota_interaction::types::{IOTA_CLOCK_OBJECT_ID, IOTA_CLOCK_OBJECT_SHARED_VERSION, TypeTag}; -use iota_interaction::{IotaClientTrait, OptionalSync}; +use iota_interaction::{IotaClientTrait, OptionalSync, ident_str}; use product_common::core_client::CoreClientReadOnly; use serde::Serialize; - -use crate::error::Error; +use serde::{Deserialize, Deserializer}; +use std::fmt::Debug; +use std::hash::Hash; /// Adds a reference to the on-chain clock to `ptb`'s arguments. pub(crate) fn get_clock_ref(ptb: &mut Ptb) -> Argument { @@ -119,3 +126,55 @@ pub(crate) async fn get_shared_object_arg( _ => Err(Error::InvalidArgument("object is not shared".to_string())), } } + +/// Deserialize a [`VecMap`] into a [`HashMap`] +pub(crate) fn deserialize_vec_map<'de, D, K, V>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, + K: Deserialize<'de> + Eq + Hash + Debug, + V: Deserialize<'de> + Debug, +{ + let vec_map = VecMap::::deserialize(deserializer)?; + Ok(vec_map + .contents + .into_iter() + .map(|entry| (entry.key, entry.value)) + .collect()) +} + +/// Deserialize a [`VecSet`] into a [`HashSet`] +pub(crate) fn deserialize_vec_set<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, + T: Deserialize<'de> + Eq + Hash, +{ + let vec_set = VecSet::::deserialize(deserializer)?; + Ok(vec_set.contents.into_iter().collect()) +} + +/// Convert an option value into a [`ProgrammableMoveCall`] argument +pub(crate) fn option_to_move( + option: Option, + tag: TypeTag, + ptb: &mut ProgrammableTransactionBuilder, +) -> Result { + let arg = if let Some(t) = option { + ptb.programmable_move_call( + MOVE_STDLIB_PACKAGE_ID, + STD_OPTION_MODULE_NAME.into(), + ident_str!("some").into(), + vec![tag], + vec![t], + ) + } else { + ptb.programmable_move_call( + MOVE_STDLIB_PACKAGE_ID, + STD_OPTION_MODULE_NAME.into(), + ident_str!("none").into(), + vec![tag], + vec![], + ) + }; + + Ok(arg) +} diff --git a/audit-trail-rs/tests/e2e/records.rs b/audit-trail-rs/tests/e2e/records.rs index e6390a82..1c2b7775 100644 --- a/audit-trail-rs/tests/e2e/records.rs +++ b/audit-trail-rs/tests/e2e/records.rs @@ -18,10 +18,6 @@ async fn add_and_fetch_record_roundtrip() -> anyhow::Result<()> { .output; let trail_id = created.trail_id; - assert!( - created.admin_capability_id.is_some(), - "admin capability id should be returned" - ); let output = client .trail(trail_id) From 0f1c81b33e30d344d7fc5b52530a37dbe40d6164 Mon Sep 17 00:00:00 2001 From: Yasir Date: Mon, 9 Feb 2026 18:24:20 +0300 Subject: [PATCH 054/189] refactor: Rename create_trail_tx to create_trail and streamline related utility functions --- audit-trail-rs/src/core/create/mod.rs | 2 +- audit-trail-rs/src/core/create/operations.rs | 14 ++++--- audit-trail-rs/src/core/types/audit_trail.rs | 40 +++++++++++++++++--- audit-trail-rs/src/core/types/metadata.rs | 7 +--- 4 files changed, 45 insertions(+), 18 deletions(-) diff --git a/audit-trail-rs/src/core/create/mod.rs b/audit-trail-rs/src/core/create/mod.rs index 658d8f12..bc3fb023 100644 --- a/audit-trail-rs/src/core/create/mod.rs +++ b/audit-trail-rs/src/core/create/mod.rs @@ -68,7 +68,7 @@ impl CreateTrail { updatable_metadata, } = self.builder.clone(); - CreateOps::create_trail_tx( + CreateOps::create_trail( client.package_id(), initial_data, initial_record_metadata, diff --git a/audit-trail-rs/src/core/create/operations.rs b/audit-trail-rs/src/core/create/operations.rs index e522d0a4..9fc22f72 100644 --- a/audit-trail-rs/src/core/create/operations.rs +++ b/audit-trail-rs/src/core/create/operations.rs @@ -5,14 +5,15 @@ use iota_interaction::ident_str; use iota_interaction::types::base_types::ObjectID; use iota_interaction::types::transaction::ProgrammableTransaction; -use crate::core::utils; use crate::core::types::{Data, ImmutableMetadata, LockingConfig}; +use crate::core::utils; use crate::error::Error; +use iota_interaction::types::programmable_transaction_builder::ProgrammableTransactionBuilder; pub(super) struct CreateOps; impl CreateOps { - pub(super) fn create_trail_tx( + pub(super) fn create_trail( package_id: ObjectID, initial_data: Option, initial_record_metadata: Option, @@ -20,20 +21,21 @@ impl CreateOps { trail_metadata: Option, updatable_metadata: Option, ) -> Result { - let mut ptb = iota_interaction::types::programmable_transaction_builder::ProgrammableTransactionBuilder::new(); + let mut ptb = ProgrammableTransactionBuilder::new(); let initial_data_arg = match initial_data { Some(data) => utils::ptb_pure(&mut ptb, "initial_data", Some(data))?, None => utils::ptb_pure::>(&mut ptb, "initial_data", None)?, }; - let initial_record_metadata = - utils::ptb_pure(&mut ptb, "initial_record_metadata", initial_record_metadata)?; + let initial_record_metadata = utils::ptb_pure(&mut ptb, "initial_record_metadata", initial_record_metadata)?; let locking_config = utils::ptb_pure(&mut ptb, "locking_config", locking_config)?; + let trail_metadata = match trail_metadata { - Some(metadata) => utils::ptb_pure(&mut ptb, "trail_metadata", Some(metadata))?, + Some(metadata) => metadata.to_ptb(&mut ptb, package_id)?, None => utils::ptb_pure::>(&mut ptb, "trail_metadata", None)?, }; + let updatable_metadata = utils::ptb_pure(&mut ptb, "updatable_metadata", updatable_metadata)?; let clock = utils::get_clock_ref(&mut ptb); diff --git a/audit-trail-rs/src/core/types/audit_trail.rs b/audit-trail-rs/src/core/types/audit_trail.rs index 1c4058b1..040fb1a8 100644 --- a/audit-trail-rs/src/core/types/audit_trail.rs +++ b/audit-trail-rs/src/core/types/audit_trail.rs @@ -1,18 +1,19 @@ use std::collections::HashMap; +use iota_interaction::ident_str; // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use iota_interaction::types::base_types::IotaAddress; +use iota_interaction::types::base_types::{IotaAddress, ObjectID}; use iota_interaction::types::collection_types::LinkedTable; use iota_interaction::types::id::UID; +use iota_interaction::types::transaction::Argument; use serde::{Deserialize, Serialize}; -use crate::core::types::Data; - use super::locking::LockingConfig; -use super::metadata::ImmutableMetadata; -use super::record::Record; use super::role_map::RoleMap; +use crate::core::utils; +use crate::error::Error; +use iota_interaction::types::programmable_transaction_builder::ProgrammableTransactionBuilder as Ptb; /// An audit trail stored on-chain. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -28,3 +29,32 @@ pub struct OnChainAuditTrail { pub updatable_metadata: Option, pub version: u64, } + +/// Metadata set at trail creation and never updated. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ImmutableMetadata { + pub name: String, + pub description: Option, +} + +impl ImmutableMetadata { + pub fn new(name: String, description: Option) -> Self { + Self { name, description } + } + + /// Creates a new `Argument` from the `ImmutableMetadata`. + /// + /// To be used when creating a new `ImmutableMetadata` object on the ledger. + pub(in crate::core) fn to_ptb(&self, ptb: &mut Ptb, package_id: ObjectID) -> Result { + let name = utils::ptb_pure(ptb, "name", &self.name)?; + let description = utils::ptb_pure(ptb, "description", &self.description)?; + + Ok(ptb.programmable_move_call( + package_id, + ident_str!("main").into(), + ident_str!("new_trail_metadata").into(), + vec![], + vec![name, description], + )) + } +} diff --git a/audit-trail-rs/src/core/types/metadata.rs b/audit-trail-rs/src/core/types/metadata.rs index 62a8ac26..00fe1e69 100644 --- a/audit-trail-rs/src/core/types/metadata.rs +++ b/audit-trail-rs/src/core/types/metadata.rs @@ -1,11 +1,6 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +use iota_interaction::types::programmable_transaction_builder::ProgrammableTransactionBuilder as Ptb; use serde::{Deserialize, Serialize}; -/// Metadata set at trail creation and never updated. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ImmutableMetadata { - pub name: String, - pub description: Option, -} From c44e69ce83e5539a1ade6c8dca8ece78771ac6c0 Mon Sep 17 00:00:00 2001 From: Yasir Date: Tue, 10 Feb 2026 15:29:33 +0300 Subject: [PATCH 055/189] Refactor audit trail creation and record management --- Cargo.toml | 2 +- audit-trail-move/Move.lock | 34 ++++- audit-trail-move/sources/audit_trail.move | 12 +- audit-trail-rs/Cargo.toml | 1 + audit-trail-rs/src/client/full_client.rs | 5 +- audit-trail-rs/src/core/builder.rs | 8 + audit-trail-rs/src/core/create/mod.rs | 37 ++++- audit-trail-rs/src/core/create/operations.rs | 38 +++-- audit-trail-rs/src/core/records/mod.rs | 14 +- audit-trail-rs/src/core/records/operations.rs | 39 ++--- audit-trail-rs/src/core/types/audit_trail.rs | 7 + audit-trail-rs/src/core/types/capability.rs | 9 +- audit-trail-rs/src/core/types/event.rs | 21 ++- audit-trail-rs/src/core/types/locking.rs | 38 +++++ audit-trail-rs/src/core/types/metadata.rs | 6 - audit-trail-rs/src/core/types/mod.rs | 2 - audit-trail-rs/src/core/types/record.rs | 33 ++++ .../tests/e2e/audit_trail_creations.rs | 142 ++++++++++++++++++ audit-trail-rs/tests/e2e/client.rs | 18 ++- audit-trail-rs/tests/e2e/main.rs | 1 + audit-trail-rs/tests/e2e/records.rs | 29 ++-- 21 files changed, 401 insertions(+), 95 deletions(-) delete mode 100644 audit-trail-rs/src/core/types/metadata.rs create mode 100644 audit-trail-rs/tests/e2e/audit_trail_creations.rs diff --git a/Cargo.toml b/Cargo.toml index 31738514..b7b75401 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,7 +26,7 @@ serde = { version = "1.0", default-features = false, features = ["alloc", "deriv serde_json = { version = "1.0", default-features = false } strum = { version = "0.27", default-features = false, features = ["std", "derive"] } thiserror = { version = "2.0", default-features = false } - +serde-aux = { version = "4.7.0", default-features = false } chrono = { version = "0.4", default-features = false } secret-storage = { git = "https://github.com/iotaledger/secret-storage.git", tag = "v0.3.0", default-features = false } sha2 = { version = "0.10", default-features = false } diff --git a/audit-trail-move/Move.lock b/audit-trail-move/Move.lock index b59fb99a..a899f99f 100644 --- a/audit-trail-move/Move.lock +++ b/audit-trail-move/Move.lock @@ -2,18 +2,19 @@ [move] version = 3 -manifest_digest = "86C91D3D3A6313FBF00CE187BE48E5E590F256C0805BBA9F9CA2E5E2C7FBFE71" -deps_digest = "F9B494B64F0615AED0E98FC12A85B85ECD2BC5185C22D30E7F67786BB52E507C" +manifest_digest = "7FAADB221F87213D4EB687B179D501CAD603866603B43FD12199C657A3C68FE9" +deps_digest = "397E6A9F7A624706DBDFEE056CE88391A15876868FD18A88504DA74EB458D697" dependencies = [ { id = "Iota", name = "Iota" }, { id = "IotaSystem", name = "IotaSystem" }, { id = "MoveStdlib", name = "MoveStdlib" }, { id = "Stardust", name = "Stardust" }, + { id = "TfComponents", name = "TfComponents" }, ] [[move.package]] id = "Iota" -source = { git = "https://github.com/iotaledger/iota.git", rev = "4698c6723208e052a00c74602d2c8dc0efffe5de", subdir = "crates/iota-framework/packages/iota-framework" } +source = { git = "https://github.com/iotaledger/iota.git", rev = "e694e2ee8f2f9f0b9b03b843a24ff0f7bcff2930", subdir = "crates/iota-framework/packages/iota-framework" } dependencies = [ { id = "MoveStdlib", name = "MoveStdlib" }, @@ -21,7 +22,7 @@ dependencies = [ [[move.package]] id = "IotaSystem" -source = { git = "https://github.com/iotaledger/iota.git", rev = "4698c6723208e052a00c74602d2c8dc0efffe5de", subdir = "crates/iota-framework/packages/iota-system" } +source = { git = "https://github.com/iotaledger/iota.git", rev = "e694e2ee8f2f9f0b9b03b843a24ff0f7bcff2930", subdir = "crates/iota-framework/packages/iota-system" } dependencies = [ { id = "Iota", name = "Iota" }, @@ -30,18 +31,37 @@ dependencies = [ [[move.package]] id = "MoveStdlib" -source = { git = "https://github.com/iotaledger/iota.git", rev = "4698c6723208e052a00c74602d2c8dc0efffe5de", subdir = "crates/iota-framework/packages/move-stdlib" } +source = { git = "https://github.com/iotaledger/iota.git", rev = "e694e2ee8f2f9f0b9b03b843a24ff0f7bcff2930", subdir = "crates/iota-framework/packages/move-stdlib" } [[move.package]] id = "Stardust" -source = { git = "https://github.com/iotaledger/iota.git", rev = "4698c6723208e052a00c74602d2c8dc0efffe5de", subdir = "crates/iota-framework/packages/stardust" } +source = { git = "https://github.com/iotaledger/iota.git", rev = "e694e2ee8f2f9f0b9b03b843a24ff0f7bcff2930", subdir = "crates/iota-framework/packages/stardust" } dependencies = [ { id = "Iota", name = "Iota" }, { id = "MoveStdlib", name = "MoveStdlib" }, ] +[[move.package]] +id = "TfComponents" +source = { git = "https://github.com/iotaledger/product-core.git", rev = "feat/role-map", subdir = "components_move" } + +dependencies = [ + { id = "Iota", name = "Iota" }, + { id = "IotaSystem", name = "IotaSystem" }, + { id = "MoveStdlib", name = "MoveStdlib" }, + { id = "Stardust", name = "Stardust" }, +] + [move.toolchain-version] -compiler-version = "1.15.0" +compiler-version = "1.16.2-rc" edition = "2024.beta" flavor = "iota" + +[env] + +[env.localnet] +chain-id = "4991e514" +original-published-id = "0x78333f8467286f1a561a19c7f4ff1baf80a1077b3d5dc2d12f891cbd262cb2b0" +latest-published-id = "0x78333f8467286f1a561a19c7f4ff1baf80a1077b3d5dc2d12f891cbd262cb2b0" +published-version = "1" diff --git a/audit-trail-move/sources/audit_trail.move b/audit-trail-move/sources/audit_trail.move index 76c1a851..72327c26 100644 --- a/audit-trail-move/sources/audit_trail.move +++ b/audit-trail-move/sources/audit_trail.move @@ -129,8 +129,8 @@ public fun new_trail_metadata(name: String, description: Option): Immuta /// roles and issue capabilities to other users. /// * Trail ID public fun create( - initial_data: Option, - initial_record_metadata: Option, + data: Option, + record_metadata: Option, locking_config: LockingConfig, trail_metadata: Option, updatable_metadata: Option, @@ -146,10 +146,10 @@ public fun create( let mut records = linked_table::new>(ctx); let mut sequence_number = 0; - if (initial_data.is_some()) { + if (data.is_some()) { let record = record::new( - initial_data.destroy_some(), - initial_record_metadata, + data.destroy_some(), + record_metadata, 0, creator, timestamp, @@ -166,7 +166,7 @@ public fun create( timestamp, }); } else { - initial_data.destroy_none(); + data.destroy_none(); }; let role_admin_permissions = role_map::new_role_admin_permissions( diff --git a/audit-trail-rs/Cargo.toml b/audit-trail-rs/Cargo.toml index 8d7c5281..7902a1cd 100644 --- a/audit-trail-rs/Cargo.toml +++ b/audit-trail-rs/Cargo.toml @@ -23,6 +23,7 @@ serde.workspace = true serde_json.workspace = true strum.workspace = true thiserror.workspace = true +serde-aux = { workspace = true, default-features = false } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] iota_interaction_rust = { workspace = true, default-features = false } diff --git a/audit-trail-rs/src/client/full_client.rs b/audit-trail-rs/src/client/full_client.rs index 9c2932e9..1faf5df5 100644 --- a/audit-trail-rs/src/client/full_client.rs +++ b/audit-trail-rs/src/client/full_client.rs @@ -143,7 +143,10 @@ impl AuditTrailClient { /// Creates a builder for an audit trail. pub fn create_trail(&self) -> AuditTrailBuilder { - AuditTrailBuilder::default() + AuditTrailBuilder { + admin: self.public_key.as_ref().map(IotaAddress::from), + ..AuditTrailBuilder::default() + } } pub async fn migrate(&self, _trail_id: ObjectID) -> Result<(), Error> { diff --git a/audit-trail-rs/src/core/builder.rs b/audit-trail-rs/src/core/builder.rs index acfc2fa2..5df8ef14 100644 --- a/audit-trail-rs/src/core/builder.rs +++ b/audit-trail-rs/src/core/builder.rs @@ -3,6 +3,7 @@ //! Audit trail builder for creation transactions. +use iota_sdk::types::base_types::IotaAddress; use product_common::transaction::transaction_builder::TransactionBuilder; use super::types::{Data, ImmutableMetadata, LockingConfig}; @@ -12,6 +13,7 @@ use crate::error::Error; /// Builder for creating an audit trail. #[derive(Debug, Clone, Default)] pub struct AuditTrailBuilder { + pub admin: Option, pub initial_data: Option, pub initial_record_metadata: Option, pub locking_config: LockingConfig, @@ -54,6 +56,12 @@ impl AuditTrailBuilder { self } + /// Sets the admin address that receives the initial admin capability. + pub fn with_admin(mut self, admin: IotaAddress) -> Self { + self.admin = Some(admin); + self + } + /// Finalizes the builder and creates a transaction builder. pub fn finish(self) -> Result, Error> { Ok(TransactionBuilder::new(CreateTrail::new(self))) diff --git a/audit-trail-rs/src/core/create/mod.rs b/audit-trail-rs/src/core/create/mod.rs index bc3fb023..4d8058de 100644 --- a/audit-trail-rs/src/core/create/mod.rs +++ b/audit-trail-rs/src/core/create/mod.rs @@ -2,9 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 use async_trait::async_trait; +use iota_interaction::IotaClientTrait; use iota_interaction::OptionalSync; use iota_interaction::rpc_types::{ - IotaTransactionBlockEffects, IotaTransactionBlockEffectsAPI, IotaTransactionBlockEvents, + IotaData as _, IotaObjectDataOptions, IotaTransactionBlockEffects, IotaTransactionBlockEvents, }; use iota_interaction::types::base_types::ObjectID; use iota_interaction::types::transaction::ProgrammableTransaction; @@ -33,10 +34,25 @@ impl TrailCreated { where C: CoreClientReadOnly + OptionalSync, { - client - .get_object_by_id(self.trail_id) + let data = client + .client_adapter() + .read_api() + .get_object_with_options(self.trail_id, IotaObjectDataOptions::bcs_lossless()) .await - .map_err(|e| Error::UnexpectedApiResponse(format!("failed to load trail {}; {e}", self.trail_id))) + .map_err(|e| Error::UnexpectedApiResponse(format!("failed to fetch trail {} object; {e}", self.trail_id)))? + .data + .ok_or_else(|| Error::UnexpectedApiResponse(format!("trail {} data not found", self.trail_id)))?; + + data.bcs + .ok_or_else(|| Error::UnexpectedApiResponse(format!("trail {} missing bcs object content", self.trail_id)))? + .try_into_move() + .ok_or_else(|| { + Error::UnexpectedApiResponse(format!("trail {} bcs content is not a move object", self.trail_id)) + })? + .deserialize() + .map_err(|e| { + Error::UnexpectedApiResponse(format!("failed to decode trail {} bcs data; {e}", self.trail_id)) + }) } } @@ -61,6 +77,7 @@ impl CreateTrail { C: CoreClientReadOnly + OptionalSync, { let AuditTrailBuilder { + admin, initial_data, initial_record_metadata, locking_config, @@ -68,8 +85,16 @@ impl CreateTrail { updatable_metadata, } = self.builder.clone(); + let admin = admin.ok_or_else(|| { + Error::InvalidArgument( + "admin address is required; use `client.create_trail()` with signer or call `with_admin(...)`" + .to_string(), + ) + })?; + CreateOps::create_trail( client.package_id(), + admin, initial_data, initial_record_metadata, locking_config, @@ -94,9 +119,9 @@ impl Transaction for CreateTrail { async fn apply_with_events( mut self, - effects: &mut IotaTransactionBlockEffects, + _: &mut IotaTransactionBlockEffects, events: &mut IotaTransactionBlockEvents, - client: &C, + _: &C, ) -> Result where C: CoreClientReadOnly + OptionalSync, diff --git a/audit-trail-rs/src/core/create/operations.rs b/audit-trail-rs/src/core/create/operations.rs index 9fc22f72..3ca209ee 100644 --- a/audit-trail-rs/src/core/create/operations.rs +++ b/audit-trail-rs/src/core/create/operations.rs @@ -2,7 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 use iota_interaction::ident_str; -use iota_interaction::types::base_types::ObjectID; +use iota_interaction::types::base_types::{IotaAddress, ObjectID}; +use iota_interaction::types::transaction::Argument; use iota_interaction::types::transaction::ProgrammableTransaction; use crate::core::types::{Data, ImmutableMetadata, LockingConfig}; @@ -15,6 +16,7 @@ pub(super) struct CreateOps; impl CreateOps { pub(super) fn create_trail( package_id: ObjectID, + admin: IotaAddress, initial_data: Option, initial_record_metadata: Option, locking_config: LockingConfig, @@ -23,27 +25,37 @@ impl CreateOps { ) -> Result { let mut ptb = ProgrammableTransactionBuilder::new(); - let initial_data_arg = match initial_data { - Some(data) => utils::ptb_pure(&mut ptb, "initial_data", Some(data))?, - None => utils::ptb_pure::>(&mut ptb, "initial_data", None)?, - }; + let initial_data = initial_data.ok_or_else(|| { + Error::InvalidArgument( + "initial_data is required to infer trail record type; use `with_initial_record(...)`".to_string(), + ) + })?; + let data_tag = initial_data.tag(); + let initial_data_arg = initial_data.to_option_ptb(&mut ptb, "initial_data")?; let initial_record_metadata = utils::ptb_pure(&mut ptb, "initial_record_metadata", initial_record_metadata)?; - let locking_config = utils::ptb_pure(&mut ptb, "locking_config", locking_config)?; + let locking_config = locking_config.to_ptb(&mut ptb, package_id)?; + + let immutable_metadata_tag = ImmutableMetadata::tag(package_id); let trail_metadata = match trail_metadata { - Some(metadata) => metadata.to_ptb(&mut ptb, package_id)?, - None => utils::ptb_pure::>(&mut ptb, "trail_metadata", None)?, + Some(metadata) => { + let metadata_arg = metadata.to_ptb(&mut ptb, package_id)?; + utils::option_to_move(Some(metadata_arg), immutable_metadata_tag, &mut ptb) + .map_err(|e| Error::InvalidArgument(format!("failed to build trail_metadata option: {e}")))? + } + None => utils::option_to_move(None, immutable_metadata_tag, &mut ptb) + .map_err(|e| Error::InvalidArgument(format!("failed to build trail_metadata option: {e}")))?, }; let updatable_metadata = utils::ptb_pure(&mut ptb, "updatable_metadata", updatable_metadata)?; let clock = utils::get_clock_ref(&mut ptb); - ptb.programmable_move_call( + let result = ptb.programmable_move_call( package_id, ident_str!("main").into(), ident_str!("create").into(), - vec![], + vec![data_tag], vec![ initial_data_arg, initial_record_metadata, @@ -54,6 +66,12 @@ impl CreateOps { ], ); + let cap = match result { + Argument::Result(idx) => Argument::NestedResult(idx, 0), + _ => unreachable!("programmable_move_call should always return Argument::Result"), + }; + ptb.transfer_arg(admin, cap); + Ok(ptb.finish()) } } diff --git a/audit-trail-rs/src/core/records/mod.rs b/audit-trail-rs/src/core/records/mod.rs index f8944451..395b8468 100644 --- a/audit-trail-rs/src/core/records/mod.rs +++ b/audit-trail-rs/src/core/records/mod.rs @@ -46,7 +46,7 @@ impl<'a, C, D> TrailRecords<'a, C, D> { C: AuditTrailReadOnly, D: DeserializeOwned, { - let tx = RecordsOps::get_record_tx(self.client, self.trail_id, sequence_number).await?; + let tx = RecordsOps::get_record(self.client, self.trail_id, sequence_number).await?; self.client.execute_read_only_transaction(tx).await } @@ -114,7 +114,7 @@ impl<'a, C, D> TrailRecords<'a, C, D> { where C: AuditTrailReadOnly, { - let tx = RecordsOps::has_record_tx(self.client, self.trail_id, sequence_number).await?; + let tx = RecordsOps::has_record(self.client, self.trail_id, sequence_number).await?; self.client.execute_read_only_transaction(tx).await } @@ -122,7 +122,7 @@ impl<'a, C, D> TrailRecords<'a, C, D> { where C: AuditTrailReadOnly, { - let tx = RecordsOps::first_sequence_tx(self.client, self.trail_id).await?; + let tx = RecordsOps::first_sequence(self.client, self.trail_id).await?; self.client.execute_read_only_transaction(tx).await } @@ -130,7 +130,7 @@ impl<'a, C, D> TrailRecords<'a, C, D> { where C: AuditTrailReadOnly, { - let tx = RecordsOps::last_sequence_tx(self.client, self.trail_id).await?; + let tx = RecordsOps::last_sequence(self.client, self.trail_id).await?; self.client.execute_read_only_transaction(tx).await } @@ -138,7 +138,7 @@ impl<'a, C, D> TrailRecords<'a, C, D> { where C: AuditTrailReadOnly, { - let tx = RecordsOps::record_count_tx(self.client, self.trail_id).await?; + let tx = RecordsOps::record_count(self.client, self.trail_id).await?; self.client.execute_read_only_transaction(tx).await } @@ -222,7 +222,7 @@ impl AddRecord { where C: CoreClientReadOnly + OptionalSync, { - RecordsOps::add_record_tx( + RecordsOps::add_record( client, self.trail_id, self.owner, @@ -294,7 +294,7 @@ impl DeleteRecord { where C: CoreClientReadOnly + OptionalSync, { - RecordsOps::delete_record_tx(client, self.trail_id, self.owner, self.sequence_number).await + RecordsOps::delete_record(client, self.trail_id, self.owner, self.sequence_number).await } } diff --git a/audit-trail-rs/src/core/records/operations.rs b/audit-trail-rs/src/core/records/operations.rs index 62cc0bbf..107f7472 100644 --- a/audit-trail-rs/src/core/records/operations.rs +++ b/audit-trail-rs/src/core/records/operations.rs @@ -3,20 +3,21 @@ use std::str::FromStr; +use iota_interaction::types::TypeTag; use iota_interaction::types::base_types::{IotaAddress, ObjectID, ObjectRef}; use iota_interaction::types::programmable_transaction_builder::ProgrammableTransactionBuilder; use iota_interaction::types::transaction::{Argument, ObjectArg, ProgrammableTransaction}; use iota_interaction::{OptionalSync, ident_str}; use product_common::core_client::CoreClientReadOnly; -use crate::core::utils; use crate::core::types::{Capability, Data}; +use crate::core::utils; use crate::error::Error; pub(super) struct RecordsOps; impl RecordsOps { - pub(super) async fn add_record_tx( + pub(super) async fn add_record( client: &C, trail_id: ObjectID, owner: IotaAddress, @@ -26,11 +27,10 @@ impl RecordsOps { where C: CoreClientReadOnly + OptionalSync, { - Self::build_trail_transaction_for_owner(client, trail_id, owner, "add_record", |ptb| { - let data_arg = match data { - Data::Bytes(bytes) => utils::ptb_pure(ptb, "stored_data", bytes)?, - Data::Text(text) => utils::ptb_pure(ptb, "stored_data", text)?, - }; + Self::build_trail_transaction_for_owner(client, trail_id, owner, "add_record", |ptb, trail_tag| { + data.ensure_matches_tag(trail_tag)?; + + let data_arg = data.to_ptb(ptb, "stored_data")?; let metadata = utils::ptb_pure(ptb, "record_metadata", record_metadata)?; let clock = utils::get_clock_ref(ptb); Ok(vec![data_arg, metadata, clock]) @@ -38,7 +38,7 @@ impl RecordsOps { .await } - pub(super) async fn delete_record_tx( + pub(super) async fn delete_record( client: &C, trail_id: ObjectID, owner: IotaAddress, @@ -47,7 +47,7 @@ impl RecordsOps { where C: CoreClientReadOnly + OptionalSync, { - Self::build_trail_transaction_for_owner(client, trail_id, owner, "delete_record", |ptb| { + Self::build_trail_transaction_for_owner(client, trail_id, owner, "delete_record", |ptb, _| { let seq = utils::ptb_pure(ptb, "sequence_number", sequence_number)?; let clock = utils::get_clock_ref(ptb); Ok(vec![seq, clock]) @@ -55,7 +55,7 @@ impl RecordsOps { .await } - pub(super) async fn get_record_tx( + pub(super) async fn get_record( client: &C, trail_id: ObjectID, sequence_number: u64, @@ -70,7 +70,7 @@ impl RecordsOps { .await } - pub(super) async fn has_record_tx( + pub(super) async fn has_record( client: &C, trail_id: ObjectID, sequence_number: u64, @@ -85,21 +85,21 @@ impl RecordsOps { .await } - pub(super) async fn record_count_tx(client: &C, trail_id: ObjectID) -> Result + pub(super) async fn record_count(client: &C, trail_id: ObjectID) -> Result where C: CoreClientReadOnly + OptionalSync, { Self::build_read_only_transaction(client, trail_id, "record_count", |_| Ok(vec![])).await } - pub(super) async fn first_sequence_tx(client: &C, trail_id: ObjectID) -> Result + pub(super) async fn first_sequence(client: &C, trail_id: ObjectID) -> Result where C: CoreClientReadOnly + OptionalSync, { Self::build_read_only_transaction(client, trail_id, "first_sequence", |_| Ok(vec![])).await } - pub(super) async fn last_sequence_tx(client: &C, trail_id: ObjectID) -> Result + pub(super) async fn last_sequence(client: &C, trail_id: ObjectID) -> Result where C: CoreClientReadOnly + OptionalSync, { @@ -114,7 +114,7 @@ impl RecordsOps { additional_args: F, ) -> Result where - F: FnOnce(&mut ProgrammableTransactionBuilder) -> Result, Error>, + F: FnOnce(&mut ProgrammableTransactionBuilder, &TypeTag) -> Result, Error>, C: CoreClientReadOnly + OptionalSync, { let cap_ref = Self::get_capability_ref(client, owner, trail_id).await?; @@ -129,12 +129,13 @@ impl RecordsOps { additional_args: F, ) -> Result where - F: FnOnce(&mut ProgrammableTransactionBuilder) -> Result, Error>, + F: FnOnce(&mut ProgrammableTransactionBuilder, &TypeTag) -> Result, Error>, C: CoreClientReadOnly + OptionalSync, { let mut ptb = ProgrammableTransactionBuilder::new(); - let tag = vec![utils::get_type_tag(client, &trail_id).await?]; + let type_tag = utils::get_type_tag(client, &trail_id).await?; + let tag = vec![type_tag.clone()]; let trail_arg = utils::get_shared_object_arg(client, &trail_id, true).await?; let mut args = vec![ @@ -144,7 +145,7 @@ impl RecordsOps { .map_err(|e| Error::InvalidArgument(format!("Failed to create cap argument: {e}")))?, ]; - args.extend(additional_args(&mut ptb)?); + args.extend(additional_args(&mut ptb, &type_tag)?); let function = iota_interaction::types::Identifier::from_str(method.as_ref()) .map_err(|e| Error::InvalidArgument(format!("Invalid method name '{}': {e}", method.as_ref())))?; @@ -159,7 +160,7 @@ impl RecordsOps { C: CoreClientReadOnly + OptionalSync, { let cap: Capability = client - .find_object_for_address(owner, |cap: &Capability| cap.security_vault_id == trail_id) + .find_object_for_address(owner, |cap: &Capability| cap.target_key == trail_id) .await .map_err(|e| Error::RpcError(e.to_string()))? .ok_or_else(|| { diff --git a/audit-trail-rs/src/core/types/audit_trail.rs b/audit-trail-rs/src/core/types/audit_trail.rs index 040fb1a8..55d2a6a7 100644 --- a/audit-trail-rs/src/core/types/audit_trail.rs +++ b/audit-trail-rs/src/core/types/audit_trail.rs @@ -1,8 +1,10 @@ use std::collections::HashMap; +use std::str::FromStr; use iota_interaction::ident_str; // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +use iota_interaction::types::TypeTag; use iota_interaction::types::base_types::{IotaAddress, ObjectID}; use iota_interaction::types::collection_types::LinkedTable; use iota_interaction::types::id::UID; @@ -42,6 +44,11 @@ impl ImmutableMetadata { Self { name, description } } + pub(in crate::core) fn tag(package_id: ObjectID) -> TypeTag { + TypeTag::from_str(&format!("{package_id}::main::ImmutableMetadata")) + .expect("invalid TypeTag for ImmutableMetadata") + } + /// Creates a new `Argument` from the `ImmutableMetadata`. /// /// To be used when creating a new `ImmutableMetadata` object on the ledger. diff --git a/audit-trail-rs/src/core/types/capability.rs b/audit-trail-rs/src/core/types/capability.rs index 24ea55f7..2a3ba710 100644 --- a/audit-trail-rs/src/core/types/capability.rs +++ b/audit-trail-rs/src/core/types/capability.rs @@ -1,10 +1,10 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +use iota_interaction::MoveType; +use iota_interaction::types::TypeTag; use iota_interaction::types::base_types::{IotaAddress, ObjectID}; use iota_interaction::types::id::UID; -use iota_interaction::types::TypeTag; -use iota_interaction::MoveType; use serde::{Deserialize, Serialize}; use std::str::FromStr; @@ -12,7 +12,7 @@ use std::str::FromStr; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Capability { pub id: UID, - pub security_vault_id: ObjectID, + pub target_key: ObjectID, pub role: String, pub issued_to: Option, pub valid_from: Option, @@ -21,7 +21,6 @@ pub struct Capability { impl MoveType for Capability { fn move_type(package: ObjectID) -> TypeTag { - TypeTag::from_str(format!("{package}::capability::Capability").as_str()) - .expect("failed to create type tag") + TypeTag::from_str(format!("{package}::capability::Capability").as_str()).expect("failed to create type tag") } } diff --git a/audit-trail-rs/src/core/types/event.rs b/audit-trail-rs/src/core/types/event.rs index 28861523..9b23c725 100644 --- a/audit-trail-rs/src/core/types/event.rs +++ b/audit-trail-rs/src/core/types/event.rs @@ -2,8 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 use iota_interaction::types::base_types::{IotaAddress, ObjectID}; -use serde::{Deserialize, Serialize}; - +use serde::{Deserialize, Serialize, ser}; +use serde_aux::field_attributes::{deserialize_number_from_string, deserialize_option_number_from_string}; /// Generic wrapper for audit trail events. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Event { @@ -15,54 +15,63 @@ pub struct Event { pub struct AuditTrailCreated { pub trail_id: ObjectID, pub creator: IotaAddress, + #[serde(deserialize_with = "deserialize_number_from_string")] pub timestamp: u64, - pub has_initial_record: bool, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AuditTrailDeleted { pub trail_id: ObjectID, + #[serde(deserialize_with = "deserialize_number_from_string")] pub timestamp: u64, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RecordAdded { pub trail_id: ObjectID, + #[serde(deserialize_with = "deserialize_number_from_string")] pub sequence_number: u64, pub added_by: IotaAddress, + #[serde(deserialize_with = "deserialize_number_from_string")] pub timestamp: u64, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RecordDeleted { pub trail_id: ObjectID, + #[serde(deserialize_with = "deserialize_number_from_string")] pub sequence_number: u64, pub deleted_by: IotaAddress, + #[serde(deserialize_with = "deserialize_number_from_string")] pub timestamp: u64, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CapabilityIssued { - pub security_vault_id: ObjectID, + pub target_key: ObjectID, pub capability_id: ObjectID, pub role: String, pub issued_to: Option, + #[serde(deserialize_with = "deserialize_option_number_from_string")] pub valid_from: Option, + #[serde(deserialize_with = "deserialize_option_number_from_string")] pub valid_until: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CapabilityDestroyed { - pub security_vault_id: ObjectID, + pub target_key: ObjectID, pub capability_id: ObjectID, pub role: String, pub issued_to: Option, + #[serde(deserialize_with = "deserialize_option_number_from_string")] pub valid_from: Option, + #[serde(deserialize_with = "deserialize_option_number_from_string")] pub valid_until: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CapabilityRevoked { - pub security_vault_id: ObjectID, + pub target_key: ObjectID, pub capability_id: ObjectID, } diff --git a/audit-trail-rs/src/core/types/locking.rs b/audit-trail-rs/src/core/types/locking.rs index ab51682c..70a3b076 100644 --- a/audit-trail-rs/src/core/types/locking.rs +++ b/audit-trail-rs/src/core/types/locking.rs @@ -1,8 +1,15 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +use iota_interaction::ident_str; +use iota_interaction::types::base_types::ObjectID; +use iota_interaction::types::programmable_transaction_builder::ProgrammableTransactionBuilder as Ptb; +use iota_interaction::types::transaction::Argument; use serde::{Deserialize, Serialize}; +use crate::core::utils; +use crate::error::Error; + /// Locking configuration for the audit trail. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] pub struct LockingConfig { @@ -27,6 +34,21 @@ impl LockingConfig { delete_record_lock: LockingWindow::count_based(count), } } + + /// Creates a new `Argument` from the `LockingConfig`. + /// + /// To be used when creating or updating locking config on the ledger. + pub(in crate::core) fn to_ptb(&self, ptb: &mut Ptb, package_id: ObjectID) -> Result { + let delete_record_lock = self.delete_record_lock.to_ptb(ptb, package_id)?; + + Ok(ptb.programmable_move_call( + package_id, + ident_str!("locking").into(), + ident_str!("new").into(), + vec![], + vec![delete_record_lock], + )) + } } /// Defines a locking window (time or count based). @@ -57,4 +79,20 @@ impl LockingWindow { count_window: Some(count), } } + + /// Creates a new `Argument` from the `LockingWindow`. + /// + /// To be used when creating or updating locking config on the ledger. + pub(in crate::core) fn to_ptb(&self, ptb: &mut Ptb, package_id: ObjectID) -> Result { + let time_window_seconds = utils::ptb_pure(ptb, "time_window_seconds", self.time_window_seconds)?; + let count_window = utils::ptb_pure(ptb, "count_window", self.count_window)?; + + Ok(ptb.programmable_move_call( + package_id, + ident_str!("locking").into(), + ident_str!("new_window").into(), + vec![], + vec![time_window_seconds, count_window], + )) + } } diff --git a/audit-trail-rs/src/core/types/metadata.rs b/audit-trail-rs/src/core/types/metadata.rs deleted file mode 100644 index 00fe1e69..00000000 --- a/audit-trail-rs/src/core/types/metadata.rs +++ /dev/null @@ -1,6 +0,0 @@ -// Copyright 2020-2026 IOTA Stiftung -// SPDX-License-Identifier: Apache-2.0 - -use iota_interaction::types::programmable_transaction_builder::ProgrammableTransactionBuilder as Ptb; -use serde::{Deserialize, Serialize}; - diff --git a/audit-trail-rs/src/core/types/mod.rs b/audit-trail-rs/src/core/types/mod.rs index 180a606f..03b336c0 100644 --- a/audit-trail-rs/src/core/types/mod.rs +++ b/audit-trail-rs/src/core/types/mod.rs @@ -7,7 +7,6 @@ pub mod audit_trail; pub mod capability; pub mod event; pub mod locking; -pub mod metadata; pub mod permission; pub mod record; pub mod role_map; @@ -16,7 +15,6 @@ pub use audit_trail::*; pub use capability::*; pub use event::*; pub use locking::*; -pub use metadata::*; pub use permission::*; pub use record::*; pub use role_map::*; diff --git a/audit-trail-rs/src/core/types/record.rs b/audit-trail-rs/src/core/types/record.rs index b90af64e..043fa23d 100644 --- a/audit-trail-rs/src/core/types/record.rs +++ b/audit-trail-rs/src/core/types/record.rs @@ -4,9 +4,12 @@ use std::str::FromStr; use iota_interaction::types::base_types::IotaAddress; +use iota_interaction::types::programmable_transaction_builder::ProgrammableTransactionBuilder as Ptb; +use iota_interaction::types::transaction::Argument; use iota_interaction::types::{MOVE_STDLIB_PACKAGE_ID, TypeTag}; use serde::{Deserialize, Deserializer, Serialize}; +use crate::core::utils; use crate::error::Error; use std::collections::{HashMap, HashSet}; @@ -92,6 +95,36 @@ impl Data { } } + /// Creates a PTB argument for `D` where `D` is the concrete Move data type. + pub(in crate::core) fn to_ptb(self, ptb: &mut Ptb, name: &str) -> Result { + match self { + Data::Bytes(bytes) => utils::ptb_pure(ptb, name, bytes), + Data::Text(text) => utils::ptb_pure(ptb, name, text), + } + } + + /// Creates a PTB argument for `Option` where `D` is the concrete Move data type. + pub(in crate::core) fn to_option_ptb(self, ptb: &mut Ptb, name: &str) -> Result { + match self { + Data::Bytes(bytes) => utils::ptb_pure(ptb, name, Some(bytes)), + Data::Text(text) => utils::ptb_pure(ptb, name, Some(text)), + } + } + + /// Validates that this data payload matches the on-chain trail data type. + pub(in crate::core) fn ensure_matches_tag(&self, expected: &TypeTag) -> Result<(), Error> { + let actual = self.tag(); + + if &actual == expected { + Ok(()) + } else { + Err(Error::InvalidArgument(format!( + "record data type mismatch: provided {:?}, trail expects {:?}", + actual, expected + ))) + } + } + /// Creates a new `Data` from bytes. pub fn bytes(data: impl Into>) -> Self { Self::Bytes(data.into()) diff --git a/audit-trail-rs/tests/e2e/audit_trail_creations.rs b/audit-trail-rs/tests/e2e/audit_trail_creations.rs new file mode 100644 index 00000000..b626c145 --- /dev/null +++ b/audit-trail-rs/tests/e2e/audit_trail_creations.rs @@ -0,0 +1,142 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use std::str::FromStr; + +use crate::client::{e2e_test_guard, get_funded_test_client}; +use audit_trails::core::types::{Capability, Data, ImmutableMetadata, LockingConfig}; +use iota_interaction::rpc_types::IotaParsedData; +use iota_interaction::types::base_types::IotaAddress; +use iota_interaction::{IotaClientTrait, OptionalSync}; +use iota_sdk::types::base_types::{ObjectID, ObjectRef}; +use product_common::core_client::{CoreClient, CoreClientReadOnly}; + +#[tokio::test] +async fn create_trail_with_default_builder_settings() -> anyhow::Result<()> { + // let _guard = e2e_test_guard().await; + let client = get_funded_test_client().await?; + + let created = client + .create_trail() + .with_initial_record(Data::text("audit-trail-create-default"), None) + .finish()? + .build_and_execute(&client) + .await? + .output; + + assert_eq!(created.creator, client.sender_address()); + println!("Created trail with ID: {}", created.trail_id); + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + + let on_chain = created.load_on_chain(&client).await?; + assert_eq!(on_chain.id.object_id(), &created.trail_id); + assert_eq!(on_chain.creator, client.sender_address()); + assert_eq!(on_chain.sequence_number, 1); + assert_eq!(on_chain.locking_config, LockingConfig::none()); + assert!(on_chain.immutable_metadata.is_none()); + assert!(on_chain.updatable_metadata.is_none()); + + Ok(()) +} + +#[tokio::test] +async fn create_trail_with_metadata_and_time_lock() -> anyhow::Result<()> { + let _guard = e2e_test_guard().await; + let client = get_funded_test_client().await?; + + let immutable_metadata = + ImmutableMetadata::new("Trail Time Lock".to_string(), Some("immutable description".to_string())); + + let created = client + .create_trail() + .with_initial_record( + Data::text("audit-trail-create-time-lock"), + Some("initial record metadata".to_string()), + ) + .with_locking_config(LockingConfig::time_based(300)) + .with_trail_metadata(immutable_metadata.clone()) + .with_updatable_metadata("updatable metadata") + .finish()? + .build_and_execute(&client) + .await? + .output; + + let on_chain = created.load_on_chain(&client).await?; + assert_eq!(on_chain.locking_config, LockingConfig::time_based(300)); + assert_eq!(on_chain.immutable_metadata, Some(immutable_metadata)); + assert_eq!(on_chain.updatable_metadata, Some("updatable metadata".to_string())); + + Ok(()) +} + +#[tokio::test] +async fn create_trail_with_bytes_and_count_lock() -> anyhow::Result<()> { + // let _guard = e2e_test_guard().await; + let client = get_funded_test_client().await?; + + let created = client + .create_trail() + .with_initial_record( + Data::bytes(vec![0xAA, 0xBB, 0xCC, 0xDD]), + Some("bytes metadata".to_string()), + ) + .with_locking_config(LockingConfig::count_based(3)) + .with_trail_metadata_parts("Trail Count Lock", Some("count lock description".to_string())) + .finish()? + .build_and_execute(&client) + .await? + .output; + + let on_chain = created.load_on_chain(&client).await?; + assert_eq!(on_chain.locking_config, LockingConfig::count_based(3)); + assert_eq!(on_chain.sequence_number, 1); + + Ok(()) +} + +#[tokio::test] +async fn create_trail_with_custom_admin_address() -> anyhow::Result<()> { + // let _guard = e2e_test_guard().await; + let client = get_funded_test_client().await?; + let custom_admin = IotaAddress::from_str("0x1111111111111111111111111111111111111111111111111111111111111111")?; + + let created = client + .create_trail() + .with_admin(custom_admin) + .with_initial_record(Data::text("audit-trail-custom-admin"), None) + .finish()? + .build_and_execute(&client) + .await? + .output; + + let cap = get_cap(&client, custom_admin, created.trail_id).await; + + println!("Owned objects for custom admin {custom_admin}:"); + match cap { + Ok(cap_ref) => println!("Found accredit capability with ID: {}", cap_ref.0), + Err(e) => println!("Error finding accredit capability for custom admin: {e}"), + } + // assert!(has_admin_capability, "custom admin did not receive admin capability"); + + Ok(()) +} + +pub(crate) async fn get_cap(client: &C, owner: IotaAddress, trail_id: ObjectID) -> anyhow::Result +where + C: CoreClientReadOnly + OptionalSync, +{ + let cap: Capability = client + .find_object_for_address(owner, |cap: &Capability| cap.target_key == trail_id) + .await + .map_err(|e| anyhow::anyhow!("Failed to find accredit cap for owner {owner} and trail {trail_id}: {e}"))? + .ok_or_else(|| anyhow::anyhow!("No accredit capability found for owner {owner} and trail {trail_id}"))?; + + let object_id = *cap.id.object_id(); + + Ok(client + .get_object_ref_by_id(object_id) + .await + .map_err(|e| anyhow::anyhow!("Failed to get object ref for accredit cap: {e}"))? + .map(|owned_ref| owned_ref.reference.to_object_ref()) + .unwrap()) +} diff --git a/audit-trail-rs/tests/e2e/client.rs b/audit-trail-rs/tests/e2e/client.rs index eb1e60a6..723d4c61 100644 --- a/audit-trail-rs/tests/e2e/client.rs +++ b/audit-trail-rs/tests/e2e/client.rs @@ -2,8 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 use std::ops::Deref; -use std::str::FromStr; use std::sync::Arc; +use std::sync::OnceLock; use audit_trails::AuditTrailClient; use iota_interaction::types::base_types::{IotaAddress, ObjectID}; @@ -15,8 +15,10 @@ use product_common::network_name::NetworkName; use product_common::test_utils::{ TEST_GAS_BUDGET, get_active_address, get_balance, init_product_package, request_funds, }; +use tokio::sync::{Mutex, MutexGuard, OnceCell}; -pub const ENV_PACKAGE_ID: &str = "AUDIT_TRAIL_PACKAGE_ID"; +static PACKAGE_ID: OnceCell = OnceCell::const_new(); +static E2E_MUTEX: OnceLock> = OnceLock::new(); /// Script file for publishing the package. pub const PUBLISH_SCRIPT_FILE: &str = concat!( @@ -28,6 +30,10 @@ pub async fn get_funded_test_client() -> anyhow::Result { TestClient::new().await } +pub async fn e2e_test_guard() -> MutexGuard<'static, ()> { + E2E_MUTEX.get_or_init(|| Mutex::new(())).lock().await +} + #[derive(Clone)] pub struct TestClient { client: Arc>, @@ -49,10 +55,10 @@ impl TestClient { pub async fn new_from_address(address: IotaAddress) -> anyhow::Result { let api_endpoint = std::env::var("API_ENDPOINT").unwrap_or_else(|_| IOTA_LOCAL_NETWORK_URL.to_string()); let client = IotaClientBuilder::default().build(&api_endpoint).await?; - let package_id = match std::env::var(ENV_PACKAGE_ID) { - Ok(value) => ObjectID::from_str(&value)?, - Err(_) => init_product_package(&client, None, Some(PUBLISH_SCRIPT_FILE)).await?, - }; + let package_id = PACKAGE_ID + .get_or_try_init(|| init_product_package(&client, None, Some(PUBLISH_SCRIPT_FILE))) + .await + .copied()?; let balance = get_balance(address).await?; if balance < TEST_GAS_BUDGET { diff --git a/audit-trail-rs/tests/e2e/main.rs b/audit-trail-rs/tests/e2e/main.rs index f06ff3ca..5cedebed 100644 --- a/audit-trail-rs/tests/e2e/main.rs +++ b/audit-trail-rs/tests/e2e/main.rs @@ -2,4 +2,5 @@ // SPDX-License-Identifier: Apache-2.0 mod client; +mod audit_trail_creations; mod records; diff --git a/audit-trail-rs/tests/e2e/records.rs b/audit-trail-rs/tests/e2e/records.rs index 1c2b7775..b1e13a4d 100644 --- a/audit-trail-rs/tests/e2e/records.rs +++ b/audit-trail-rs/tests/e2e/records.rs @@ -1,11 +1,12 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use crate::client::get_funded_test_client; +use crate::client::{e2e_test_guard, get_funded_test_client}; use audit_trails::core::types::Data; #[tokio::test] async fn add_and_fetch_record_roundtrip() -> anyhow::Result<()> { + let _guard = e2e_test_guard().await; let client = get_funded_test_client().await?; let metadata = Some("audit-trail-e2e".to_string()); @@ -19,20 +20,22 @@ async fn add_and_fetch_record_roundtrip() -> anyhow::Result<()> { let trail_id = created.trail_id; - let output = client - .trail(trail_id) - .records() - .add(Data::text("audit-trail-e2e"), metadata.clone())? - .build_and_execute(&client) - .await?; + println!("Created trail with ID: {trail_id}"); + + // let output = client + // .trail(trail_id) + // .records() + // .add(Data::text("audit-trail-e2e"), metadata.clone())? + // .build_and_execute(&client) + // .await?; - let added = output.output; - assert_eq!(added.trail_id, trail_id); + // let added = output.output; + // assert_eq!(added.trail_id, trail_id); - let record = client.trail(trail_id).records().get(added.sequence_number).await?; - assert_eq!(record.sequence_number, added.sequence_number); - assert_eq!(record.metadata, metadata); - assert_eq!(record.data, Data::text("audit-trail-e2e")); + // let record = client.trail(trail_id).records().get(added.sequence_number).await?; + // assert_eq!(record.sequence_number, added.sequence_number); + // assert_eq!(record.metadata, metadata); + // assert_eq!(record.data, Data::text("audit-trail-e2e")); Ok(()) } From 1059f7b7c3b01aea9d84904b9cab7ce4995351da Mon Sep 17 00:00:00 2001 From: Yasir Date: Mon, 16 Feb 2026 13:13:04 +0300 Subject: [PATCH 056/189] Implement role management operations and associated events in the audit trail module - Added `RolesOps` struct with methods for creating, updating, deleting roles, and issuing capabilities. - Removed the `capability` module and integrated its functionality into the `role_map` module. - Introduced new event types: `RoleCreated`, `RoleUpdated`, and `RoleDeleted` to track role changes. - Updated the `AuditTrailHandle` to remove capability-related methods and focus on role management. - Created comprehensive end-to-end tests for role creation, permission updates, capability issuance, revocation, and destruction. - Refactored client tests to remove unnecessary dependencies and streamline functionality. --- audit-trail-move/Move.lock | 6 +- audit-trail-move/sources/audit_trail.move | 160 ++++- audit-trail-rs/src/core/capabilities/mod.rs | 40 -- audit-trail-rs/src/core/mod.rs | 2 +- audit-trail-rs/src/core/operations.rs | 112 ++++ audit-trail-rs/src/core/records/operations.rs | 122 +--- audit-trail-rs/src/core/roles/mod.rs | 588 +++++++++++++++++- audit-trail-rs/src/core/roles/operations.rs | 223 +++++++ audit-trail-rs/src/core/trail.rs | 4 - audit-trail-rs/src/core/types/capability.rs | 26 - audit-trail-rs/src/core/types/event.rs | 38 +- audit-trail-rs/src/core/types/mod.rs | 2 - audit-trail-rs/src/core/types/role_map.rs | 30 + .../tests/e2e/audit_trail_creations.rs | 41 +- audit-trail-rs/tests/e2e/client.rs | 63 +- audit-trail-rs/tests/e2e/main.rs | 1 + audit-trail-rs/tests/e2e/records.rs | 3 +- audit-trail-rs/tests/e2e/roles.rs | 229 +++++++ notarization-move/Move.history.json | 6 +- notarization-move/Move.lock | 24 +- 20 files changed, 1433 insertions(+), 287 deletions(-) delete mode 100644 audit-trail-rs/src/core/capabilities/mod.rs create mode 100644 audit-trail-rs/src/core/operations.rs create mode 100644 audit-trail-rs/src/core/roles/operations.rs delete mode 100644 audit-trail-rs/src/core/types/capability.rs create mode 100644 audit-trail-rs/tests/e2e/roles.rs diff --git a/audit-trail-move/Move.lock b/audit-trail-move/Move.lock index a899f99f..74e49424 100644 --- a/audit-trail-move/Move.lock +++ b/audit-trail-move/Move.lock @@ -61,7 +61,7 @@ flavor = "iota" [env] [env.localnet] -chain-id = "4991e514" -original-published-id = "0x78333f8467286f1a561a19c7f4ff1baf80a1077b3d5dc2d12f891cbd262cb2b0" -latest-published-id = "0x78333f8467286f1a561a19c7f4ff1baf80a1077b3d5dc2d12f891cbd262cb2b0" +chain-id = "b7526e0b" +original-published-id = "0x9365325d9bde0de55eaddd2ebe8e17b159b18fbeddbc9c82af0e0a01328878e1" +latest-published-id = "0x9365325d9bde0de55eaddd2ebe8e17b159b18fbeddbc9c82af0e0a01328878e1" published-version = "1" diff --git a/audit-trail-move/sources/audit_trail.move b/audit-trail-move/sources/audit_trail.move index 72327c26..5773397f 100644 --- a/audit-trail-move/sources/audit_trail.move +++ b/audit-trail-move/sources/audit_trail.move @@ -13,7 +13,7 @@ use audit_trail::{ record::{Self, Record}, record_correction }; -use iota::{clock::{Self, Clock}, event, linked_table::{Self, LinkedTable}}; +use iota::{clock::{Self, Clock}, event, linked_table::{Self, LinkedTable}, vec_set::VecSet}; use std::string::String; use tf_components::{capability::Capability, role_map::{Self, RoleMap}}; @@ -102,6 +102,32 @@ public struct RecordDeleted has copy, drop { timestamp: u64, } +/// Emitted when a role is created +public struct RoleCreated has copy, drop { + trail_id: ID, + role: String, + permissions: VecSet, + created_by: address, + timestamp: u64, +} + +/// Emitted when a role's permissions are updated +public struct RoleUpdated has copy, drop { + trail_id: ID, + role: String, + new_permissions: VecSet, + updated_by: address, + timestamp: u64, +} + +/// Emitted when a role is deleted +public struct RoleDeleted has copy, drop { + trail_id: ID, + role: String, + deleted_by: address, + timestamp: u64, +} + // ===== Constructors ===== /// Create immutable trail metadata @@ -423,6 +449,138 @@ public fun update_metadata( trail.updatable_metadata = new_metadata; } +// ===== Role and Capability Administration ===== + +/// Creates a new role with the provided permissions. +public fun create_role( + trail: &mut AuditTrail, + cap: &Capability, + role: String, + permissions: VecSet, + clock: &Clock, + ctx: &TxContext, +) { + assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); + role_map::create_role(trail.roles_mut(), cap, role, permissions, clock, ctx); + event::emit(RoleCreated { + trail_id: trail.id(), + role, + permissions, + created_by: ctx.sender(), + timestamp: clock::timestamp_ms(clock), + }); +} + +/// Updates permissions for an existing role. +public fun update_role_permissions( + trail: &mut AuditTrail, + cap: &Capability, + role: String, + new_permissions: VecSet, + clock: &Clock, + ctx: &TxContext, +) { + assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); + role_map::update_role_permissions(trail.roles_mut(), cap, &role, new_permissions, clock, ctx); + event::emit(RoleUpdated { + trail_id: trail.id(), + role, + new_permissions, + updated_by: ctx.sender(), + timestamp: clock::timestamp_ms(clock), + }); +} + +/// Deletes an existing role. +public fun delete_role( + trail: &mut AuditTrail, + cap: &Capability, + role: String, + clock: &Clock, + ctx: &TxContext, +) { + assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); + role_map::delete_role(trail.roles_mut(), cap, &role, clock, ctx); + event::emit(RoleDeleted { + trail_id: trail.id(), + role, + deleted_by: ctx.sender(), + timestamp: clock::timestamp_ms(clock), + }); +} + +/// Issues a new capability for an existing role. +/// +/// The capability object is transferred to `issued_to` if provided, otherwise to the caller. +public fun new_capability( + trail: &mut AuditTrail, + cap: &Capability, + role: String, + issued_to: Option

, + valid_from: Option, + valid_until: Option, + clock: &Clock, + ctx: &mut TxContext, +) { + assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); + + let recipient = if (issued_to.is_some()) { + let address_ref = issued_to.borrow(); + *address_ref + } else { + ctx.sender() + }; + + let new_cap = role_map::new_capability( + trail.roles_mut(), + cap, + &role, + issued_to, + valid_from, + valid_until, + clock, + ctx, + ); + transfer::public_transfer(new_cap, recipient); +} + +/// Revokes an issued capability by ID. +public fun revoke_capability( + trail: &mut AuditTrail, + cap: &Capability, + capability_id: ID, + clock: &Clock, + ctx: &TxContext, +) { + assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); + role_map::revoke_capability(trail.roles_mut(), cap, capability_id, clock, ctx); +} + +/// Destroys a capability object. +/// +/// Requires a capability with `RevokeCapabilities` permission. +public fun destroy_capability( + trail: &mut AuditTrail, + cap: &Capability, + cap_to_destroy: Capability, + clock: &Clock, + ctx: &TxContext, +) { + assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); + assert!( + trail + .roles + .is_capability_valid( + cap, + &permission::revoke_capabilities(), + clock, + ctx, + ), + EPermissionDenied, + ); + role_map::destroy_capability(trail.roles_mut(), cap_to_destroy); +} + // ===== Trail Query Functions ===== /// Get the total number of records currently in the trail diff --git a/audit-trail-rs/src/core/capabilities/mod.rs b/audit-trail-rs/src/core/capabilities/mod.rs deleted file mode 100644 index 2aa04ea6..00000000 --- a/audit-trail-rs/src/core/capabilities/mod.rs +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright 2020-2026 IOTA Stiftung -// SPDX-License-Identifier: Apache-2.0 - -use iota_interaction::types::base_types::ObjectID; - -use crate::core::trail::AuditTrailFull; -use crate::error::Error; - -#[derive(Debug, Clone)] -pub struct TrailCapabilities<'a, C> { - pub(crate) client: &'a C, - pub(crate) trail_id: ObjectID, -} - -impl<'a, C> TrailCapabilities<'a, C> { - pub(crate) fn new(client: &'a C, trail_id: ObjectID) -> Self { - Self { client, trail_id } - } - - pub async fn issue(&self, _role: String) -> Result<(), Error> - where - C: AuditTrailFull, - { - Err(Error::NotImplemented("TrailCapabilities::issue")) - } - - pub async fn revoke(&self, _capability_id: ObjectID) -> Result<(), Error> - where - C: AuditTrailFull, - { - Err(Error::NotImplemented("TrailCapabilities::revoke")) - } - - pub async fn destroy(&self, _capability_id: ObjectID) -> Result<(), Error> - where - C: AuditTrailFull, - { - Err(Error::NotImplemented("TrailCapabilities::destroy")) - } -} diff --git a/audit-trail-rs/src/core/mod.rs b/audit-trail-rs/src/core/mod.rs index 1f89684e..75146665 100644 --- a/audit-trail-rs/src/core/mod.rs +++ b/audit-trail-rs/src/core/mod.rs @@ -4,10 +4,10 @@ //! Core types and builders for audit trails. pub mod builder; -pub mod capabilities; pub mod create; pub mod locking; pub mod metadata; +pub(crate) mod operations; pub(crate) mod utils; pub mod records; pub mod roles; diff --git a/audit-trail-rs/src/core/operations.rs b/audit-trail-rs/src/core/operations.rs new file mode 100644 index 00000000..457a837e --- /dev/null +++ b/audit-trail-rs/src/core/operations.rs @@ -0,0 +1,112 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use std::str::FromStr; + +use iota_interaction::types::TypeTag; +use iota_interaction::types::base_types::{IotaAddress, ObjectID, ObjectRef}; +use iota_interaction::types::programmable_transaction_builder::ProgrammableTransactionBuilder; +use iota_interaction::types::transaction::{Argument, ObjectArg, ProgrammableTransaction}; +use iota_interaction::{OptionalSync, ident_str}; +use product_common::core_client::CoreClientReadOnly; + +use crate::core::types::Capability; +use crate::core::utils; +use crate::error::Error; + +pub(crate) async fn build_trail_transaction_for_owner( + client: &C, + trail_id: ObjectID, + owner: IotaAddress, + method: impl AsRef, + additional_args: F, +) -> Result +where + F: FnOnce(&mut ProgrammableTransactionBuilder, &TypeTag) -> Result, Error>, + C: CoreClientReadOnly + OptionalSync, +{ + let cap_ref = get_capability_ref(client, owner, trail_id).await?; + build_trail_transaction_with_cap_ref(client, trail_id, cap_ref, method, additional_args).await +} + +pub(crate) async fn build_trail_transaction_with_cap_ref( + client: &C, + trail_id: ObjectID, + cap_ref: ObjectRef, + method: impl AsRef, + additional_args: F, +) -> Result +where + F: FnOnce(&mut ProgrammableTransactionBuilder, &TypeTag) -> Result, Error>, + C: CoreClientReadOnly + OptionalSync, +{ + let mut ptb = ProgrammableTransactionBuilder::new(); + + let type_tag = utils::get_type_tag(client, &trail_id).await?; + let tag = vec![type_tag.clone()]; + let trail_arg = utils::get_shared_object_arg(client, &trail_id, true).await?; + + let mut args = vec![ + ptb.obj(trail_arg) + .map_err(|e| Error::InvalidArgument(format!("Failed to create trail argument: {e}")))?, + ptb.obj(ObjectArg::ImmOrOwnedObject(cap_ref)) + .map_err(|e| Error::InvalidArgument(format!("Failed to create cap argument: {e}")))?, + ]; + + args.extend(additional_args(&mut ptb, &type_tag)?); + + let function = iota_interaction::types::Identifier::from_str(method.as_ref()) + .map_err(|e| Error::InvalidArgument(format!("Invalid method name '{}': {e}", method.as_ref())))?; + + ptb.programmable_move_call(client.package_id(), ident_str!("main").into(), function, tag, args); + + Ok(ptb.finish()) +} + +pub(crate) async fn get_capability_ref( + client: &C, + owner: IotaAddress, + trail_id: ObjectID, +) -> Result +where + C: CoreClientReadOnly + OptionalSync, +{ + let cap: Capability = client + .find_object_for_address(owner, |cap: &Capability| cap.target_key == trail_id) + .await + .map_err(|e| Error::RpcError(e.to_string()))? + .ok_or_else(|| Error::InvalidArgument(format!("no capability found for owner {owner} and trail {trail_id}")))?; + + let object_id = *cap.id.object_id(); + utils::get_object_ref_by_id(client, &object_id).await +} + +pub(crate) async fn build_read_only_transaction( + client: &C, + trail_id: ObjectID, + method: impl AsRef, + additional_args: F, +) -> Result +where + F: FnOnce(&mut ProgrammableTransactionBuilder) -> Result, Error>, + C: CoreClientReadOnly + OptionalSync, +{ + let mut ptb = ProgrammableTransactionBuilder::new(); + + let tag = vec![utils::get_type_tag(client, &trail_id).await?]; + let trail_arg = utils::get_shared_object_arg(client, &trail_id, false).await?; + + let mut args = vec![ + ptb.obj(trail_arg) + .map_err(|e| Error::InvalidArgument(format!("Failed to create trail argument: {e}")))?, + ]; + + args.extend(additional_args(&mut ptb)?); + + let function = iota_interaction::types::Identifier::from_str(method.as_ref()) + .map_err(|e| Error::InvalidArgument(format!("Invalid method name '{}': {e}", method.as_ref())))?; + + ptb.programmable_move_call(client.package_id(), ident_str!("main").into(), function, tag, args); + + Ok(ptb.finish()) +} diff --git a/audit-trail-rs/src/core/records/operations.rs b/audit-trail-rs/src/core/records/operations.rs index 107f7472..0283f0f0 100644 --- a/audit-trail-rs/src/core/records/operations.rs +++ b/audit-trail-rs/src/core/records/operations.rs @@ -1,16 +1,13 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use std::str::FromStr; - -use iota_interaction::types::TypeTag; -use iota_interaction::types::base_types::{IotaAddress, ObjectID, ObjectRef}; -use iota_interaction::types::programmable_transaction_builder::ProgrammableTransactionBuilder; -use iota_interaction::types::transaction::{Argument, ObjectArg, ProgrammableTransaction}; -use iota_interaction::{OptionalSync, ident_str}; +use iota_interaction::types::base_types::{IotaAddress, ObjectID}; +use iota_interaction::types::transaction::ProgrammableTransaction; +use iota_interaction::OptionalSync; use product_common::core_client::CoreClientReadOnly; -use crate::core::types::{Capability, Data}; +use crate::core::operations; +use crate::core::types::Data; use crate::core::utils; use crate::error::Error; @@ -27,7 +24,7 @@ impl RecordsOps { where C: CoreClientReadOnly + OptionalSync, { - Self::build_trail_transaction_for_owner(client, trail_id, owner, "add_record", |ptb, trail_tag| { + operations::build_trail_transaction_for_owner(client, trail_id, owner, "add_record", |ptb, trail_tag| { data.ensure_matches_tag(trail_tag)?; let data_arg = data.to_ptb(ptb, "stored_data")?; @@ -47,7 +44,7 @@ impl RecordsOps { where C: CoreClientReadOnly + OptionalSync, { - Self::build_trail_transaction_for_owner(client, trail_id, owner, "delete_record", |ptb, _| { + operations::build_trail_transaction_for_owner(client, trail_id, owner, "delete_record", |ptb, _| { let seq = utils::ptb_pure(ptb, "sequence_number", sequence_number)?; let clock = utils::get_clock_ref(ptb); Ok(vec![seq, clock]) @@ -63,7 +60,7 @@ impl RecordsOps { where C: CoreClientReadOnly + OptionalSync, { - Self::build_read_only_transaction(client, trail_id, "get_record", |ptb| { + operations::build_read_only_transaction(client, trail_id, "get_record", |ptb| { let seq = utils::ptb_pure(ptb, "sequence_number", sequence_number)?; Ok(vec![seq]) }) @@ -78,7 +75,7 @@ impl RecordsOps { where C: CoreClientReadOnly + OptionalSync, { - Self::build_read_only_transaction(client, trail_id, "has_record", |ptb| { + operations::build_read_only_transaction(client, trail_id, "has_record", |ptb| { let seq = utils::ptb_pure(ptb, "sequence_number", sequence_number)?; Ok(vec![seq]) }) @@ -89,115 +86,20 @@ impl RecordsOps { where C: CoreClientReadOnly + OptionalSync, { - Self::build_read_only_transaction(client, trail_id, "record_count", |_| Ok(vec![])).await + operations::build_read_only_transaction(client, trail_id, "record_count", |_| Ok(vec![])).await } pub(super) async fn first_sequence(client: &C, trail_id: ObjectID) -> Result where C: CoreClientReadOnly + OptionalSync, { - Self::build_read_only_transaction(client, trail_id, "first_sequence", |_| Ok(vec![])).await + operations::build_read_only_transaction(client, trail_id, "first_sequence", |_| Ok(vec![])).await } pub(super) async fn last_sequence(client: &C, trail_id: ObjectID) -> Result where C: CoreClientReadOnly + OptionalSync, { - Self::build_read_only_transaction(client, trail_id, "last_sequence", |_| Ok(vec![])).await - } - - async fn build_trail_transaction_for_owner( - client: &C, - trail_id: ObjectID, - owner: IotaAddress, - method: impl AsRef, - additional_args: F, - ) -> Result - where - F: FnOnce(&mut ProgrammableTransactionBuilder, &TypeTag) -> Result, Error>, - C: CoreClientReadOnly + OptionalSync, - { - let cap_ref = Self::get_capability_ref(client, owner, trail_id).await?; - Self::build_trail_transaction_with_cap_ref(client, trail_id, cap_ref, method, additional_args).await - } - - async fn build_trail_transaction_with_cap_ref( - client: &C, - trail_id: ObjectID, - cap_ref: ObjectRef, - method: impl AsRef, - additional_args: F, - ) -> Result - where - F: FnOnce(&mut ProgrammableTransactionBuilder, &TypeTag) -> Result, Error>, - C: CoreClientReadOnly + OptionalSync, - { - let mut ptb = ProgrammableTransactionBuilder::new(); - - let type_tag = utils::get_type_tag(client, &trail_id).await?; - let tag = vec![type_tag.clone()]; - let trail_arg = utils::get_shared_object_arg(client, &trail_id, true).await?; - - let mut args = vec![ - ptb.obj(trail_arg) - .map_err(|e| Error::InvalidArgument(format!("Failed to create trail argument: {e}")))?, - ptb.obj(ObjectArg::ImmOrOwnedObject(cap_ref)) - .map_err(|e| Error::InvalidArgument(format!("Failed to create cap argument: {e}")))?, - ]; - - args.extend(additional_args(&mut ptb, &type_tag)?); - - let function = iota_interaction::types::Identifier::from_str(method.as_ref()) - .map_err(|e| Error::InvalidArgument(format!("Invalid method name '{}': {e}", method.as_ref())))?; - - ptb.programmable_move_call(client.package_id(), ident_str!("main").into(), function, tag, args); - - Ok(ptb.finish()) - } - - async fn get_capability_ref(client: &C, owner: IotaAddress, trail_id: ObjectID) -> Result - where - C: CoreClientReadOnly + OptionalSync, - { - let cap: Capability = client - .find_object_for_address(owner, |cap: &Capability| cap.target_key == trail_id) - .await - .map_err(|e| Error::RpcError(e.to_string()))? - .ok_or_else(|| { - Error::InvalidArgument(format!("no capability found for owner {owner} and trail {trail_id}")) - })?; - - let object_id = *cap.id.object_id(); - utils::get_object_ref_by_id(client, &object_id).await - } - - async fn build_read_only_transaction( - client: &C, - trail_id: ObjectID, - method: impl AsRef, - additional_args: F, - ) -> Result - where - F: FnOnce(&mut ProgrammableTransactionBuilder) -> Result, Error>, - C: CoreClientReadOnly + OptionalSync, - { - let mut ptb = ProgrammableTransactionBuilder::new(); - - let tag = vec![utils::get_type_tag(client, &trail_id).await?]; - let trail_arg = utils::get_shared_object_arg(client, &trail_id, false).await?; - - let mut args = vec![ - ptb.obj(trail_arg) - .map_err(|e| Error::InvalidArgument(format!("Failed to create trail argument: {e}")))?, - ]; - - args.extend(additional_args(&mut ptb)?); - - let function = iota_interaction::types::Identifier::from_str(method.as_ref()) - .map_err(|e| Error::InvalidArgument(format!("Invalid method name '{}': {e}", method.as_ref())))?; - - ptb.programmable_move_call(client.package_id(), ident_str!("main").into(), function, tag, args); - - Ok(ptb.finish()) + operations::build_read_only_transaction(client, trail_id, "last_sequence", |_| Ok(vec![])).await } } diff --git a/audit-trail-rs/src/core/roles/mod.rs b/audit-trail-rs/src/core/roles/mod.rs index b95bbea3..1f83a6e6 100644 --- a/audit-trail-rs/src/core/roles/mod.rs +++ b/audit-trail-rs/src/core/roles/mod.rs @@ -1,12 +1,26 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use iota_interaction::types::base_types::ObjectID; +use async_trait::async_trait; +use iota_interaction::types::base_types::{IotaAddress, ObjectID}; +use iota_interaction::types::transaction::ProgrammableTransaction; +use iota_interaction::{IotaKeySignature, OptionalSync}; +use product_common::core_client::{CoreClient, CoreClientReadOnly}; +use product_common::transaction::transaction_builder::{Transaction, TransactionBuilder}; +use secret_storage::Signer; +use tokio::sync::OnceCell; use crate::core::trail::AuditTrailFull; -use crate::core::types::PermissionSet; +use crate::core::types::{ + CapabilityDestroyed, CapabilityIssueOptions, CapabilityIssued, CapabilityRevoked, Event, PermissionSet, RoleCreated, + RoleDeleted, RoleUpdated, +}; use crate::error::Error; +mod operations; +use self::operations::RolesOps; +use iota_interaction::rpc_types::{IotaTransactionBlockEffects, IotaTransactionBlockEvents}; + #[derive(Debug, Clone)] pub struct TrailRoles<'a, C> { pub(crate) client: &'a C, @@ -19,32 +33,88 @@ impl<'a, C> TrailRoles<'a, C> { } /// Returns a handle bound to a specific role name. - pub fn role(&self, name: impl Into) -> RoleHandle<'a, C> { + pub fn for_role(&self, name: impl Into) -> RoleHandle<'a, C> { RoleHandle::new(self.client, self.trail_id, name.into()) } /// Creates a new role with the provided permissions. - pub async fn create(&self, _name: impl Into, _permissions: PermissionSet) -> Result<(), Error> + pub fn create( + &self, + name: impl Into, + permissions: PermissionSet, + ) -> Result, Error> where - C: AuditTrailFull, + C: AuditTrailFull + CoreClient, + S: Signer + OptionalSync, { - Err(Error::NotImplemented("TrailRoles::create")) + let owner = self.client.sender_address(); + Ok(TransactionBuilder::new(CreateRole::new( + self.trail_id, + owner, + name.into(), + permissions, + ))) } /// Updates permissions for an existing role. - pub async fn update(&self, _name: impl Into, _permissions: PermissionSet) -> Result<(), Error> + pub fn update( + &self, + name: impl Into, + permissions: PermissionSet, + ) -> Result, Error> where - C: AuditTrailFull, + C: AuditTrailFull + CoreClient, + S: Signer + OptionalSync, { - Err(Error::NotImplemented("TrailRoles::update")) + let owner = self.client.sender_address(); + Ok(TransactionBuilder::new(UpdateRole::new( + self.trail_id, + owner, + name.into(), + permissions, + ))) } /// Deletes an existing role. - pub async fn delete(&self, _name: impl Into) -> Result<(), Error> + pub fn delete(&self, name: impl Into) -> Result, Error> + where + C: AuditTrailFull + CoreClient, + S: Signer + OptionalSync, + { + let owner = self.client.sender_address(); + Ok(TransactionBuilder::new(DeleteRole::new( + self.trail_id, + owner, + name.into(), + ))) + } + + /// Revokes an issued capability. + pub fn revoke_capability(&self, capability_id: ObjectID) -> Result, Error> where - C: AuditTrailFull, + C: AuditTrailFull + CoreClient, + S: Signer + OptionalSync, { - Err(Error::NotImplemented("TrailRoles::delete")) + let owner = self.client.sender_address(); + Ok(TransactionBuilder::new(RevokeCapability::new( + self.trail_id, + owner, + capability_id, + ))) + } + + /// Destroys a capability object. + pub fn destroy_capability(&self, capability_id: ObjectID) -> Result, Error> + where + C: AuditTrailFull + CoreClient, + S: Signer + OptionalSync, + { + let owner = self.client.sender_address(); + Ok(TransactionBuilder::new(DestroyCapability::new( + self.trail_id, + owner, + capability_id, + ))) } } @@ -64,19 +134,501 @@ impl<'a, C> RoleHandle<'a, C> { &self.name } + /// Issues a capability for this role using optional restrictions. + pub fn issue_capability( + &self, + options: CapabilityIssueOptions, + ) -> Result, Error> + where + C: AuditTrailFull + CoreClient, + S: Signer + OptionalSync, + { + let owner = self.client.sender_address(); + Ok(TransactionBuilder::new(IssueCapability::new( + self.trail_id, + owner, + self.name.clone(), + options, + ))) + } + /// Updates permissions for this role. - pub async fn update_permissions(&self, _permissions: PermissionSet) -> Result<(), Error> + pub fn update_permissions(&self, permissions: PermissionSet) -> Result, Error> where - C: AuditTrailFull, + C: AuditTrailFull + CoreClient, + S: Signer + OptionalSync, { - Err(Error::NotImplemented("RoleHandle::update_permissions")) + let owner = self.client.sender_address(); + Ok(TransactionBuilder::new(UpdateRole::new( + self.trail_id, + owner, + self.name.clone(), + permissions, + ))) } /// Deletes this role. - pub async fn delete(&self) -> Result<(), Error> + pub fn delete(&self) -> Result, Error> + where + C: AuditTrailFull + CoreClient, + S: Signer + OptionalSync, + { + let owner = self.client.sender_address(); + Ok(TransactionBuilder::new(DeleteRole::new( + self.trail_id, + owner, + self.name.clone(), + ))) + } +} + +#[derive(Debug, Clone)] +pub struct CreateRole { + trail_id: ObjectID, + owner: IotaAddress, + name: String, + permissions: PermissionSet, + cached_ptb: OnceCell, +} + +impl CreateRole { + pub fn new(trail_id: ObjectID, owner: IotaAddress, name: String, permissions: PermissionSet) -> Self { + Self { + trail_id, + owner, + name, + permissions, + cached_ptb: OnceCell::new(), + } + } + + async fn make_ptb(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + RolesOps::create_role( + client, + self.trail_id, + self.owner, + self.name.clone(), + self.permissions.clone(), + ) + .await + } +} + +#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] +#[cfg_attr(feature = "send-sync", async_trait)] +impl Transaction for CreateRole { + type Error = Error; + type Output = RoleCreated; + + async fn build_programmable_transaction(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() + } + + async fn apply_with_events( + mut self, + _: &mut IotaTransactionBlockEffects, + events: &mut IotaTransactionBlockEvents, + _: &C, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + for data in &events.data { + if let Ok(event) = serde_json::from_value::>(data.parsed_json.clone()) { + return Ok(event.data); + } + } + + Err(Error::UnexpectedApiResponse("RoleCreated event not found".to_string())) + } + + async fn apply(mut self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + Err(Error::UnexpectedApiResponse( + "RoleCreated output requires transaction events".to_string(), + )) + } +} + +#[derive(Debug, Clone)] +pub struct UpdateRole { + trail_id: ObjectID, + owner: IotaAddress, + name: String, + permissions: PermissionSet, + cached_ptb: OnceCell, +} + +impl UpdateRole { + pub fn new(trail_id: ObjectID, owner: IotaAddress, name: String, permissions: PermissionSet) -> Self { + Self { + trail_id, + owner, + name, + permissions, + cached_ptb: OnceCell::new(), + } + } + + async fn make_ptb(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + RolesOps::update_role( + client, + self.trail_id, + self.owner, + self.name.clone(), + self.permissions.clone(), + ) + .await + } +} + +#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] +#[cfg_attr(feature = "send-sync", async_trait)] +impl Transaction for UpdateRole { + type Error = Error; + type Output = RoleUpdated; + + async fn build_programmable_transaction(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() + } + + async fn apply_with_events( + mut self, + _: &mut IotaTransactionBlockEffects, + events: &mut IotaTransactionBlockEvents, + _: &C, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + for data in &events.data { + if let Ok(event) = serde_json::from_value::>(data.parsed_json.clone()) { + return Ok(event.data); + } + } + + Err(Error::UnexpectedApiResponse("RoleUpdated event not found".to_string())) + } + + async fn apply(mut self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + Err(Error::UnexpectedApiResponse( + "RoleUpdated output requires transaction events".to_string(), + )) + } +} + +#[derive(Debug, Clone)] +pub struct DeleteRole { + trail_id: ObjectID, + owner: IotaAddress, + name: String, + cached_ptb: OnceCell, +} + +impl DeleteRole { + pub fn new(trail_id: ObjectID, owner: IotaAddress, name: String) -> Self { + Self { + trail_id, + owner, + name, + cached_ptb: OnceCell::new(), + } + } + + async fn make_ptb(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + RolesOps::delete_role(client, self.trail_id, self.owner, self.name.clone()).await + } +} + +#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] +#[cfg_attr(feature = "send-sync", async_trait)] +impl Transaction for DeleteRole { + type Error = Error; + type Output = RoleDeleted; + + async fn build_programmable_transaction(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() + } + + async fn apply_with_events( + mut self, + _: &mut iota_interaction::rpc_types::IotaTransactionBlockEffects, + events: &mut iota_interaction::rpc_types::IotaTransactionBlockEvents, + _: &C, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + for data in &events.data { + if let Ok(event) = serde_json::from_value::>(data.parsed_json.clone()) { + return Ok(event.data); + } + } + + Err(Error::UnexpectedApiResponse("RoleDeleted event not found".to_string())) + } + + async fn apply( + mut self, + _: &mut iota_interaction::rpc_types::IotaTransactionBlockEffects, + _: &C, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + Err(Error::UnexpectedApiResponse( + "RoleDeleted output requires transaction events".to_string(), + )) + } +} + +#[derive(Debug, Clone)] +pub struct IssueCapability { + trail_id: ObjectID, + owner: IotaAddress, + role: String, + options: CapabilityIssueOptions, + cached_ptb: OnceCell, +} + +impl IssueCapability { + pub fn new(trail_id: ObjectID, owner: IotaAddress, role: String, options: CapabilityIssueOptions) -> Self { + Self { + trail_id, + owner, + role, + options, + cached_ptb: OnceCell::new(), + } + } + + async fn make_ptb(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + RolesOps::issue_capability( + client, + self.trail_id, + self.owner, + self.role.clone(), + self.options.clone(), + ) + .await + } +} + +#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] +#[cfg_attr(feature = "send-sync", async_trait)] +impl Transaction for IssueCapability { + type Error = Error; + type Output = CapabilityIssued; + + async fn build_programmable_transaction(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() + } + + async fn apply_with_events( + mut self, + _: &mut iota_interaction::rpc_types::IotaTransactionBlockEffects, + events: &mut iota_interaction::rpc_types::IotaTransactionBlockEvents, + _: &C, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + for data in &events.data { + if let Ok(event) = serde_json::from_value::>(data.parsed_json.clone()) { + return Ok(event.data); + } + } + + Err(Error::UnexpectedApiResponse( + "CapabilityIssued event not found".to_string(), + )) + } + + async fn apply( + mut self, + _: &mut iota_interaction::rpc_types::IotaTransactionBlockEffects, + _: &C, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + Err(Error::UnexpectedApiResponse( + "CapabilityIssued output requires transaction events".to_string(), + )) + } +} + +#[derive(Debug, Clone)] +pub struct RevokeCapability { + trail_id: ObjectID, + owner: IotaAddress, + capability_id: ObjectID, + cached_ptb: OnceCell, +} + +impl RevokeCapability { + pub fn new(trail_id: ObjectID, owner: IotaAddress, capability_id: ObjectID) -> Self { + Self { + trail_id, + owner, + capability_id, + cached_ptb: OnceCell::new(), + } + } + + async fn make_ptb(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + RolesOps::revoke_capability(client, self.trail_id, self.owner, self.capability_id).await + } +} + +#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] +#[cfg_attr(feature = "send-sync", async_trait)] +impl Transaction for RevokeCapability { + type Error = Error; + type Output = CapabilityRevoked; + + async fn build_programmable_transaction(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() + } + + async fn apply_with_events( + mut self, + _: &mut iota_interaction::rpc_types::IotaTransactionBlockEffects, + events: &mut iota_interaction::rpc_types::IotaTransactionBlockEvents, + _: &C, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + for data in &events.data { + if let Ok(event) = serde_json::from_value::>(data.parsed_json.clone()) { + return Ok(event.data); + } + } + + Err(Error::UnexpectedApiResponse( + "CapabilityRevoked event not found".to_string(), + )) + } + + async fn apply( + mut self, + _: &mut iota_interaction::rpc_types::IotaTransactionBlockEffects, + _: &C, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + Err(Error::UnexpectedApiResponse( + "CapabilityRevoked output requires transaction events".to_string(), + )) + } +} + +#[derive(Debug, Clone)] +pub struct DestroyCapability { + trail_id: ObjectID, + owner: IotaAddress, + capability_id: ObjectID, + cached_ptb: OnceCell, +} + +impl DestroyCapability { + pub fn new(trail_id: ObjectID, owner: IotaAddress, capability_id: ObjectID) -> Self { + Self { + trail_id, + owner, + capability_id, + cached_ptb: OnceCell::new(), + } + } + + async fn make_ptb(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + RolesOps::destroy_capability(client, self.trail_id, self.owner, self.capability_id).await + } +} + +#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] +#[cfg_attr(feature = "send-sync", async_trait)] +impl Transaction for DestroyCapability { + type Error = Error; + type Output = CapabilityDestroyed; + + async fn build_programmable_transaction(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() + } + + async fn apply_with_events( + mut self, + _: &mut iota_interaction::rpc_types::IotaTransactionBlockEffects, + events: &mut iota_interaction::rpc_types::IotaTransactionBlockEvents, + _: &C, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + for data in &events.data { + if let Ok(event) = serde_json::from_value::>(data.parsed_json.clone()) { + return Ok(event.data); + } + } + + Err(Error::UnexpectedApiResponse( + "CapabilityDestroyed event not found".to_string(), + )) + } + + async fn apply( + mut self, + _: &mut iota_interaction::rpc_types::IotaTransactionBlockEffects, + _: &C, + ) -> Result where - C: AuditTrailFull, + C: CoreClientReadOnly + OptionalSync, { - Err(Error::NotImplemented("RoleHandle::delete")) + Err(Error::UnexpectedApiResponse( + "CapabilityDestroyed output requires transaction events".to_string(), + )) } } diff --git a/audit-trail-rs/src/core/roles/operations.rs b/audit-trail-rs/src/core/roles/operations.rs new file mode 100644 index 00000000..8a3bfdff --- /dev/null +++ b/audit-trail-rs/src/core/roles/operations.rs @@ -0,0 +1,223 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use iota_interaction::types::base_types::{IotaAddress, ObjectID}; +use iota_interaction::types::TypeTag; +use iota_interaction::types::transaction::Command; +use iota_interaction::types::transaction::ObjectArg; +use iota_interaction::types::transaction::ProgrammableTransaction; +use iota_interaction::{OptionalSync, ident_str}; +use product_common::core_client::CoreClientReadOnly; +use std::str::FromStr; + +use crate::core::operations; +use crate::core::types::{Capability, CapabilityIssueOptions, Permission, PermissionSet}; +use crate::core::utils; +use crate::error::Error; + +pub(super) struct RolesOps; + +impl RolesOps { + pub(super) async fn create_role( + client: &C, + trail_id: ObjectID, + owner: IotaAddress, + name: String, + permissions: PermissionSet, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + let admin_cap_ref = Self::get_admin_capability_ref(client, owner, trail_id).await?; + operations::build_trail_transaction_with_cap_ref(client, trail_id, admin_cap_ref, "create_role", |ptb, _| { + let role = utils::ptb_pure(ptb, "role", name)?; + let perms_vec = Self::permissions_to_vec(ptb, client.package_id(), permissions.permissions); + let perms = ptb.programmable_move_call( + client.package_id(), + ident_str!("permission").into(), + ident_str!("from_vec").into(), + vec![], + vec![perms_vec], + ); + let clock = utils::get_clock_ref(ptb); + + Ok(vec![role, perms, clock]) + }) + .await + } + + pub(super) async fn update_role( + client: &C, + trail_id: ObjectID, + owner: IotaAddress, + name: String, + permissions: PermissionSet, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + let admin_cap_ref = Self::get_admin_capability_ref(client, owner, trail_id).await?; + operations::build_trail_transaction_with_cap_ref(client, trail_id, admin_cap_ref, "update_role_permissions", |ptb, _| { + let role = utils::ptb_pure(ptb, "role", name)?; + let perms_vec = Self::permissions_to_vec(ptb, client.package_id(), permissions.permissions); + let perms = ptb.programmable_move_call( + client.package_id(), + ident_str!("permission").into(), + ident_str!("from_vec").into(), + vec![], + vec![perms_vec], + ); + let clock = utils::get_clock_ref(ptb); + + Ok(vec![role, perms, clock]) + }) + .await + } + + fn permissions_to_vec( + ptb: &mut iota_interaction::types::programmable_transaction_builder::ProgrammableTransactionBuilder, + package_id: ObjectID, + permissions: Vec, + ) -> iota_interaction::types::transaction::Argument { + let permission_type = TypeTag::from_str(format!("{package_id}::permission::Permission").as_str()) + .expect("invalid TypeTag for Permission"); + let permission_args = permissions + .into_iter() + .map(|permission| Self::permission_to_argument(ptb, package_id, permission)) + .collect::>(); + ptb.command(Command::MakeMoveVec(Some(permission_type.into()), permission_args)) + } + + fn permission_to_argument( + ptb: &mut iota_interaction::types::programmable_transaction_builder::ProgrammableTransactionBuilder, + package_id: ObjectID, + permission: Permission, + ) -> iota_interaction::types::transaction::Argument { + let function = match permission { + Permission::DeleteAuditTrail => ident_str!("delete_audit_trail").into(), + Permission::AddRecord => ident_str!("add_record").into(), + Permission::DeleteRecord => ident_str!("delete_record").into(), + Permission::CorrectRecord => ident_str!("correct_record").into(), + Permission::UpdateLockingConfig => ident_str!("update_locking_config").into(), + Permission::UpdateLockingConfigForDeleteRecord => ident_str!("update_locking_config_for_delete_record").into(), + Permission::UpdateLockingConfigForDeleteTrail => ident_str!("update_locking_config_for_delete_trail").into(), + Permission::AddRoles => ident_str!("add_roles").into(), + Permission::UpdateRoles => ident_str!("update_roles").into(), + Permission::DeleteRoles => ident_str!("delete_roles").into(), + Permission::AddCapabilities => ident_str!("add_capabilities").into(), + Permission::RevokeCapabilities => ident_str!("revoke_capabilities").into(), + Permission::UpdateMetadata => ident_str!("update_metadata").into(), + Permission::DeleteMetadata => ident_str!("delete_metadata").into(), + }; + + ptb.programmable_move_call(package_id, ident_str!("permission").into(), function, vec![], vec![]) + } + + pub(super) async fn delete_role( + client: &C, + trail_id: ObjectID, + owner: IotaAddress, + name: String, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + let admin_cap_ref = Self::get_admin_capability_ref(client, owner, trail_id).await?; + operations::build_trail_transaction_with_cap_ref(client, trail_id, admin_cap_ref, "delete_role", |ptb, _| { + let role = utils::ptb_pure(ptb, "role", name)?; + let clock = utils::get_clock_ref(ptb); + + Ok(vec![role, clock]) + }) + .await + } + + pub(super) async fn issue_capability( + client: &C, + trail_id: ObjectID, + owner: IotaAddress, + role_name: String, + options: CapabilityIssueOptions, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + let admin_cap_ref = Self::get_admin_capability_ref(client, owner, trail_id).await?; + operations::build_trail_transaction_with_cap_ref(client, trail_id, admin_cap_ref, "new_capability", |ptb, _| { + let role = utils::ptb_pure(ptb, "role", role_name)?; + let issued_to = utils::ptb_pure(ptb, "issued_to", options.issued_to)?; + let valid_from = utils::ptb_pure(ptb, "valid_from", options.valid_from_ms)?; + let valid_until = utils::ptb_pure(ptb, "valid_until", options.valid_until_ms)?; + let clock = utils::get_clock_ref(ptb); + + Ok(vec![role, issued_to, valid_from, valid_until, clock]) + }) + .await + } + + pub(super) async fn revoke_capability( + client: &C, + trail_id: ObjectID, + owner: IotaAddress, + capability_id: ObjectID, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + let admin_cap_ref = Self::get_admin_capability_ref(client, owner, trail_id).await?; + operations::build_trail_transaction_with_cap_ref(client, trail_id, admin_cap_ref, "revoke_capability", |ptb, _| { + let cap = utils::ptb_pure(ptb, "capability_id", capability_id)?; + let clock = utils::get_clock_ref(ptb); + + Ok(vec![cap, clock]) + }) + .await + } + + pub(super) async fn destroy_capability( + client: &C, + trail_id: ObjectID, + owner: IotaAddress, + capability_id: ObjectID, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + let admin_cap_ref = Self::get_admin_capability_ref(client, owner, trail_id).await?; + let capability_ref = utils::get_object_ref_by_id(client, &capability_id).await?; + + operations::build_trail_transaction_with_cap_ref(client, trail_id, admin_cap_ref, "destroy_capability", |ptb, _| { + let cap_to_destroy = ptb + .obj(ObjectArg::ImmOrOwnedObject(capability_ref)) + .map_err(|e| Error::InvalidArgument(format!("Failed to create capability argument: {e}")))?; + let clock = utils::get_clock_ref(ptb); + + Ok(vec![cap_to_destroy, clock]) + }) + .await + } + + async fn get_admin_capability_ref( + client: &C, + owner: IotaAddress, + trail_id: ObjectID, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + let admin_cap: Capability = client + .find_object_for_address(owner, |cap: &Capability| { + cap.target_key == trail_id && cap.role == "Admin" + }) + .await + .map_err(|e| Error::RpcError(e.to_string()))? + .ok_or_else(|| { + Error::InvalidArgument(format!( + "no admin capability found for owner {owner} and trail {trail_id}" + )) + })?; + + let object_id = *admin_cap.id.object_id(); + utils::get_object_ref_by_id(client, &object_id).await + } +} diff --git a/audit-trail-rs/src/core/trail.rs b/audit-trail-rs/src/core/trail.rs index d6c4957e..25acc613 100644 --- a/audit-trail-rs/src/core/trail.rs +++ b/audit-trail-rs/src/core/trail.rs @@ -7,7 +7,6 @@ use iota_interaction::types::transaction::ProgrammableTransaction; use product_common::core_client::CoreClientReadOnly; use serde::de::DeserializeOwned; -use crate::core::capabilities::TrailCapabilities; use crate::core::locking::TrailLocking; use crate::core::metadata::TrailMetadata; use crate::core::records::TrailRecords; @@ -59,7 +58,4 @@ impl<'a, C> AuditTrailHandle<'a, C> { TrailRoles::new(self.client, self.trail_id) } - pub fn capabilities(&self) -> TrailCapabilities<'a, C> { - TrailCapabilities::new(self.client, self.trail_id) - } } diff --git a/audit-trail-rs/src/core/types/capability.rs b/audit-trail-rs/src/core/types/capability.rs deleted file mode 100644 index 2a3ba710..00000000 --- a/audit-trail-rs/src/core/types/capability.rs +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright 2020-2026 IOTA Stiftung -// SPDX-License-Identifier: Apache-2.0 - -use iota_interaction::MoveType; -use iota_interaction::types::TypeTag; -use iota_interaction::types::base_types::{IotaAddress, ObjectID}; -use iota_interaction::types::id::UID; -use serde::{Deserialize, Serialize}; -use std::str::FromStr; - -/// Capability data returned by the Move capability module. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct Capability { - pub id: UID, - pub target_key: ObjectID, - pub role: String, - pub issued_to: Option, - pub valid_from: Option, - pub valid_until: Option, -} - -impl MoveType for Capability { - fn move_type(package: ObjectID) -> TypeTag { - TypeTag::from_str(format!("{package}::capability::Capability").as_str()).expect("failed to create type tag") - } -} diff --git a/audit-trail-rs/src/core/types/event.rs b/audit-trail-rs/src/core/types/event.rs index 9b23c725..4d91ff2a 100644 --- a/audit-trail-rs/src/core/types/event.rs +++ b/audit-trail-rs/src/core/types/event.rs @@ -2,8 +2,13 @@ // SPDX-License-Identifier: Apache-2.0 use iota_interaction::types::base_types::{IotaAddress, ObjectID}; -use serde::{Deserialize, Serialize, ser}; +use serde::{Deserialize, Serialize}; use serde_aux::field_attributes::{deserialize_number_from_string, deserialize_option_number_from_string}; +use std::collections::HashSet; + +use crate::core::utils::deserialize_vec_set; + +use super::permission::Permission; /// Generic wrapper for audit trail events. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Event { @@ -75,3 +80,34 @@ pub struct CapabilityRevoked { pub target_key: ObjectID, pub capability_id: ObjectID, } + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RoleCreated { + pub trail_id: ObjectID, + pub role: String, + #[serde(deserialize_with = "deserialize_vec_set")] + pub permissions: HashSet, + pub created_by: IotaAddress, + #[serde(deserialize_with = "deserialize_number_from_string")] + pub timestamp: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RoleUpdated { + pub trail_id: ObjectID, + pub role: String, + #[serde(deserialize_with = "deserialize_vec_set")] + pub new_permissions: HashSet, + pub updated_by: IotaAddress, + #[serde(deserialize_with = "deserialize_number_from_string")] + pub timestamp: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RoleDeleted { + pub trail_id: ObjectID, + pub role: String, + pub deleted_by: IotaAddress, + #[serde(deserialize_with = "deserialize_number_from_string")] + pub timestamp: u64, +} diff --git a/audit-trail-rs/src/core/types/mod.rs b/audit-trail-rs/src/core/types/mod.rs index 03b336c0..3a13679c 100644 --- a/audit-trail-rs/src/core/types/mod.rs +++ b/audit-trail-rs/src/core/types/mod.rs @@ -4,7 +4,6 @@ //! Core data types for audit trails. pub mod audit_trail; -pub mod capability; pub mod event; pub mod locking; pub mod permission; @@ -12,7 +11,6 @@ pub mod record; pub mod role_map; pub use audit_trail::*; -pub use capability::*; pub use event::*; pub use locking::*; pub use permission::*; diff --git a/audit-trail-rs/src/core/types/role_map.rs b/audit-trail-rs/src/core/types/role_map.rs index 6c32315a..1ad26fc4 100644 --- a/audit-trail-rs/src/core/types/role_map.rs +++ b/audit-trail-rs/src/core/types/role_map.rs @@ -2,8 +2,13 @@ // SPDX-License-Identifier: Apache-2.0 use std::collections::{HashMap, HashSet}; +use std::str::FromStr; +use iota_interaction::MoveType; +use iota_interaction::types::TypeTag; +use iota_interaction::types::base_types::IotaAddress; use iota_interaction::types::base_types::ObjectID; +use iota_interaction::types::id::UID; use serde::{Deserialize, Serialize}; use crate::core::utils::deserialize_vec_map; @@ -26,6 +31,31 @@ pub struct CapabilityAdminPermissions { pub revoke: Permission, } +/// Capability issuance options used by the role-based API. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct CapabilityIssueOptions { + pub issued_to: Option, + pub valid_from_ms: Option, + pub valid_until_ms: Option, +} + +/// Capability data returned by the Move capability module. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Capability { + pub id: UID, + pub target_key: ObjectID, + pub role: String, + pub issued_to: Option, + pub valid_from: Option, + pub valid_until: Option, +} + +impl MoveType for Capability { + fn move_type(package: ObjectID) -> TypeTag { + TypeTag::from_str(format!("{package}::capability::Capability").as_str()).expect("failed to create type tag") + } +} + /// A simplified Rust representation of the on-chain RoleMap. /// /// Note: The Move type uses VecMap/VecSet; this struct represents those diff --git a/audit-trail-rs/tests/e2e/audit_trail_creations.rs b/audit-trail-rs/tests/e2e/audit_trail_creations.rs index b626c145..0ed22f01 100644 --- a/audit-trail-rs/tests/e2e/audit_trail_creations.rs +++ b/audit-trail-rs/tests/e2e/audit_trail_creations.rs @@ -1,19 +1,13 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use std::str::FromStr; - -use crate::client::{e2e_test_guard, get_funded_test_client}; -use audit_trails::core::types::{Capability, Data, ImmutableMetadata, LockingConfig}; -use iota_interaction::rpc_types::IotaParsedData; +use crate::client::get_funded_test_client; +use audit_trails::core::types::{Data, ImmutableMetadata, LockingConfig}; use iota_interaction::types::base_types::IotaAddress; -use iota_interaction::{IotaClientTrait, OptionalSync}; -use iota_sdk::types::base_types::{ObjectID, ObjectRef}; -use product_common::core_client::{CoreClient, CoreClientReadOnly}; +use product_common::core_client::CoreClient; #[tokio::test] async fn create_trail_with_default_builder_settings() -> anyhow::Result<()> { - // let _guard = e2e_test_guard().await; let client = get_funded_test_client().await?; let created = client @@ -25,8 +19,6 @@ async fn create_trail_with_default_builder_settings() -> anyhow::Result<()> { .output; assert_eq!(created.creator, client.sender_address()); - println!("Created trail with ID: {}", created.trail_id); - tokio::time::sleep(std::time::Duration::from_secs(1)).await; let on_chain = created.load_on_chain(&client).await?; assert_eq!(on_chain.id.object_id(), &created.trail_id); @@ -41,7 +33,6 @@ async fn create_trail_with_default_builder_settings() -> anyhow::Result<()> { #[tokio::test] async fn create_trail_with_metadata_and_time_lock() -> anyhow::Result<()> { - let _guard = e2e_test_guard().await; let client = get_funded_test_client().await?; let immutable_metadata = @@ -71,7 +62,6 @@ async fn create_trail_with_metadata_and_time_lock() -> anyhow::Result<()> { #[tokio::test] async fn create_trail_with_bytes_and_count_lock() -> anyhow::Result<()> { - // let _guard = e2e_test_guard().await; let client = get_funded_test_client().await?; let created = client @@ -96,9 +86,8 @@ async fn create_trail_with_bytes_and_count_lock() -> anyhow::Result<()> { #[tokio::test] async fn create_trail_with_custom_admin_address() -> anyhow::Result<()> { - // let _guard = e2e_test_guard().await; let client = get_funded_test_client().await?; - let custom_admin = IotaAddress::from_str("0x1111111111111111111111111111111111111111111111111111111111111111")?; + let custom_admin = IotaAddress::random_for_testing_only(); let created = client .create_trail() @@ -109,7 +98,7 @@ async fn create_trail_with_custom_admin_address() -> anyhow::Result<()> { .await? .output; - let cap = get_cap(&client, custom_admin, created.trail_id).await; + let cap = client.get_cap(custom_admin, created.trail_id).await; println!("Owned objects for custom admin {custom_admin}:"); match cap { @@ -120,23 +109,3 @@ async fn create_trail_with_custom_admin_address() -> anyhow::Result<()> { Ok(()) } - -pub(crate) async fn get_cap(client: &C, owner: IotaAddress, trail_id: ObjectID) -> anyhow::Result -where - C: CoreClientReadOnly + OptionalSync, -{ - let cap: Capability = client - .find_object_for_address(owner, |cap: &Capability| cap.target_key == trail_id) - .await - .map_err(|e| anyhow::anyhow!("Failed to find accredit cap for owner {owner} and trail {trail_id}: {e}"))? - .ok_or_else(|| anyhow::anyhow!("No accredit capability found for owner {owner} and trail {trail_id}"))?; - - let object_id = *cap.id.object_id(); - - Ok(client - .get_object_ref_by_id(object_id) - .await - .map_err(|e| anyhow::anyhow!("Failed to get object ref for accredit cap: {e}"))? - .map(|owned_ref| owned_ref.reference.to_object_ref()) - .unwrap()) -} diff --git a/audit-trail-rs/tests/e2e/client.rs b/audit-trail-rs/tests/e2e/client.rs index 723d4c61..b98a5ac4 100644 --- a/audit-trail-rs/tests/e2e/client.rs +++ b/audit-trail-rs/tests/e2e/client.rs @@ -3,22 +3,20 @@ use std::ops::Deref; use std::sync::Arc; -use std::sync::OnceLock; use audit_trails::AuditTrailClient; +use audit_trails::core::types::Capability; use iota_interaction::types::base_types::{IotaAddress, ObjectID}; use iota_interaction::types::crypto::PublicKey; -use iota_interaction::{IOTA_LOCAL_NETWORK_URL, IotaClientBuilder, KeytoolSigner}; +use iota_interaction::{IOTA_LOCAL_NETWORK_URL, IotaClientBuilder}; use iota_interaction_rust::IotaClientAdapter; +use iota_sdk::types::base_types::ObjectRef; use product_common::core_client::{CoreClient, CoreClientReadOnly}; use product_common::network_name::NetworkName; -use product_common::test_utils::{ - TEST_GAS_BUDGET, get_active_address, get_balance, init_product_package, request_funds, -}; -use tokio::sync::{Mutex, MutexGuard, OnceCell}; +use product_common::test_utils::{InMemSigner, init_product_package, request_funds}; +use tokio::sync::OnceCell; static PACKAGE_ID: OnceCell = OnceCell::const_new(); -static E2E_MUTEX: OnceLock> = OnceLock::new(); /// Script file for publishing the package. pub const PUBLISH_SCRIPT_FILE: &str = concat!( @@ -30,17 +28,13 @@ pub async fn get_funded_test_client() -> anyhow::Result { TestClient::new().await } -pub async fn e2e_test_guard() -> MutexGuard<'static, ()> { - E2E_MUTEX.get_or_init(|| Mutex::new(())).lock().await -} - #[derive(Clone)] pub struct TestClient { - client: Arc>, + client: Arc>, } impl Deref for TestClient { - type Target = AuditTrailClient; + type Target = AuditTrailClient; fn deref(&self) -> &Self::Target { &self.client } @@ -48,31 +42,44 @@ impl Deref for TestClient { impl TestClient { pub async fn new() -> anyhow::Result { - let active_address = get_active_address().await?; - Self::new_from_address(active_address).await - } - - pub async fn new_from_address(address: IotaAddress) -> anyhow::Result { let api_endpoint = std::env::var("API_ENDPOINT").unwrap_or_else(|_| IOTA_LOCAL_NETWORK_URL.to_string()); - let client = IotaClientBuilder::default().build(&api_endpoint).await?; + let iota_client = IotaClientBuilder::default().build(&api_endpoint).await?; let package_id = PACKAGE_ID - .get_or_try_init(|| init_product_package(&client, None, Some(PUBLISH_SCRIPT_FILE))) + .get_or_try_init(|| init_product_package(&iota_client, None, Some(PUBLISH_SCRIPT_FILE))) .await .copied()?; - let balance = get_balance(address).await?; - if balance < TEST_GAS_BUDGET { - request_funds(&address).await?; - } + // Use a dedicated ephemeral signer per test to avoid object-lock contention. + let signer = InMemSigner::new(); + let signer_address = signer.get_address().await?; + request_funds(&signer_address).await?; - let signer = KeytoolSigner::builder().build()?; - let client = AuditTrailClient::from_iota_client(client.clone(), Some(package_id)).await?; + let client = AuditTrailClient::from_iota_client(iota_client.clone(), Some(package_id)).await?; let client = client.with_signer(signer).await?; Ok(TestClient { client: Arc::new(client), }) } + + pub(crate) async fn get_cap(&self, owner: IotaAddress, trail_id: ObjectID) -> anyhow::Result { + let cap: Capability = self + .client + .find_object_for_address(owner, |cap: &Capability| cap.target_key == trail_id) + .await + .map_err(|e| anyhow::anyhow!("Failed to find accredit cap for owner {owner} and trail {trail_id}: {e}"))? + .ok_or_else(|| anyhow::anyhow!("No accredit capability found for owner {owner} and trail {trail_id}"))?; + + let object_id = *cap.id.object_id(); + + Ok(self + .client + .get_object_ref_by_id(object_id) + .await + .map_err(|e| anyhow::anyhow!("Failed to get object ref for accredit cap: {e}"))? + .map(|owned_ref| owned_ref.reference.to_object_ref()) + .unwrap()) + } } impl CoreClientReadOnly for TestClient { @@ -89,8 +96,8 @@ impl CoreClientReadOnly for TestClient { } } -impl CoreClient for TestClient { - fn signer(&self) -> &KeytoolSigner { +impl CoreClient for TestClient { + fn signer(&self) -> &InMemSigner { self.client.signer() } diff --git a/audit-trail-rs/tests/e2e/main.rs b/audit-trail-rs/tests/e2e/main.rs index 5cedebed..dc8a4fb5 100644 --- a/audit-trail-rs/tests/e2e/main.rs +++ b/audit-trail-rs/tests/e2e/main.rs @@ -4,3 +4,4 @@ mod client; mod audit_trail_creations; mod records; +mod roles; diff --git a/audit-trail-rs/tests/e2e/records.rs b/audit-trail-rs/tests/e2e/records.rs index b1e13a4d..e22fd731 100644 --- a/audit-trail-rs/tests/e2e/records.rs +++ b/audit-trail-rs/tests/e2e/records.rs @@ -1,12 +1,11 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use crate::client::{e2e_test_guard, get_funded_test_client}; +use crate::client::get_funded_test_client; use audit_trails::core::types::Data; #[tokio::test] async fn add_and_fetch_record_roundtrip() -> anyhow::Result<()> { - let _guard = e2e_test_guard().await; let client = get_funded_test_client().await?; let metadata = Some("audit-trail-e2e".to_string()); diff --git a/audit-trail-rs/tests/e2e/roles.rs b/audit-trail-rs/tests/e2e/roles.rs new file mode 100644 index 00000000..b8058028 --- /dev/null +++ b/audit-trail-rs/tests/e2e/roles.rs @@ -0,0 +1,229 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use crate::client::get_funded_test_client; +use audit_trails::core::types::{CapabilityIssueOptions, Data, Permission, PermissionSet, RoleCreated}; +use iota_interaction::types::base_types::{IotaAddress, ObjectID}; +use std::collections::HashSet; + +async fn create_trail(client: &crate::client::TestClient) -> anyhow::Result { + let created = client + .create_trail() + .with_initial_record(Data::text("roles-e2e"), None) + .finish()? + .build_and_execute(client) + .await? + .output; + Ok(created.trail_id) +} + +async fn create_role_with_permissions( + client: &crate::client::TestClient, + trail_id: ObjectID, + role_name: &str, + permissions: Vec, +) -> anyhow::Result { + let expected_permissions = permissions.iter().copied().collect::>(); + let created = client + .trail(trail_id) + .roles() + .create( + role_name.to_string(), + PermissionSet { permissions }, + )? + .build_and_execute(client) + .await? + .output; + + assert_eq!(created.trail_id, trail_id); + assert_eq!(created.role, role_name); + assert_eq!(created.permissions, expected_permissions); + assert!(created.timestamp > 0); + + Ok(created) +} + +#[tokio::test] +async fn create_role_then_issue_capability_default_options() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = create_trail(&client).await?; + let roles = client.trail(trail_id).roles(); + let role_name = "auditor"; + + create_role_with_permissions(&client, trail_id, role_name, vec![Permission::AddRecord]).await?; + + let issued = roles + .for_role(role_name) + .issue_capability(CapabilityIssueOptions::default())? + .build_and_execute(&client) + .await? + .output; + + assert_eq!(issued.target_key, trail_id); + assert_eq!(issued.role, role_name.to_string()); + assert_eq!(issued.issued_to, None); + assert_eq!(issued.valid_from, None); + assert_eq!(issued.valid_until, None); + + Ok(()) +} + +#[tokio::test] +async fn update_role_permissions_then_issue_capability() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = create_trail(&client).await?; + let roles = client.trail(trail_id).roles(); + let role_name = "editor"; + + create_role_with_permissions(&client, trail_id, role_name, vec![Permission::AddRecord]).await?; + + let updated = roles + .update( + role_name.to_string(), + PermissionSet { + permissions: vec![Permission::AddRecord, Permission::DeleteRecord], + }, + )? + .build_and_execute(&client) + .await? + .output; + assert_eq!(updated.trail_id, trail_id); + assert_eq!(updated.role, role_name.to_string()); + assert_eq!( + updated.new_permissions, + [Permission::AddRecord, Permission::DeleteRecord] + .into_iter() + .collect::>() + ); + assert!(updated.timestamp > 0); + + let issued = roles + .for_role(role_name) + .issue_capability(CapabilityIssueOptions::default())? + .build_and_execute(&client) + .await? + .output; + assert_eq!(issued.target_key, trail_id); + assert_eq!(issued.role, role_name.to_string()); + + Ok(()) +} + +#[tokio::test] +async fn delete_role_prevents_new_capability_issuance() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = create_trail(&client).await?; + let roles = client.trail(trail_id).roles(); + let role_name = "to-delete"; + + create_role_with_permissions(&client, trail_id, role_name, vec![Permission::AddRecord]).await?; + let deleted = roles + .delete(role_name.to_string())? + .build_and_execute(&client) + .await? + .output; + assert_eq!(deleted.trail_id, trail_id); + assert_eq!(deleted.role, role_name.to_string()); + assert!(deleted.timestamp > 0); + + let issue_tx = roles + .for_role(role_name) + .issue_capability(CapabilityIssueOptions::default())?; + let issue_after_delete = issue_tx.build_and_execute(&client).await; + assert!( + issue_after_delete.is_err(), + "issuing a capability for a deleted role must fail" + ); + Ok(()) +} + +#[tokio::test] +async fn issue_capability_with_constraints() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = create_trail(&client).await?; + let roles = client.trail(trail_id).roles(); + let role_name = "reviewer"; + + create_role_with_permissions(&client, trail_id, role_name, vec![Permission::AddRecord]).await?; + + let issued_to = IotaAddress::random_for_testing_only(); + let constrained = CapabilityIssueOptions { + issued_to: Some(issued_to), + valid_from_ms: Some(1_700_000_000_000), + valid_until_ms: Some(1_700_000_001_000), + }; + + let issued = roles + .for_role(role_name) + .issue_capability(constrained.clone())? + .build_and_execute(&client) + .await? + .output; + + assert_eq!(issued.target_key, trail_id); + assert_eq!(issued.role, role_name.to_string()); + assert_eq!(issued.issued_to, constrained.issued_to); + assert_eq!(issued.valid_from, constrained.valid_from_ms); + assert_eq!(issued.valid_until, constrained.valid_until_ms); + + Ok(()) +} + +#[tokio::test] +async fn revoke_capability_emits_expected_event_data() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = create_trail(&client).await?; + let roles = client.trail(trail_id).roles(); + let role_name = "revoker"; + + create_role_with_permissions(&client, trail_id, role_name, vec![Permission::AddRecord]).await?; + + let issued = roles + .for_role(role_name) + .issue_capability(CapabilityIssueOptions::default())? + .build_and_execute(&client) + .await? + .output; + + let revoked = roles + .revoke_capability(issued.capability_id)? + .build_and_execute(&client) + .await? + .output; + assert_eq!(revoked.target_key, trail_id); + assert_eq!(revoked.capability_id, issued.capability_id); + + Ok(()) +} + +#[tokio::test] +async fn destroy_capability_emits_expected_event_data() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = create_trail(&client).await?; + let roles = client.trail(trail_id).roles(); + let role_name = "destroyer"; + + create_role_with_permissions(&client, trail_id, role_name, vec![Permission::AddRecord]).await?; + + let issued_for_destroy = roles + .for_role(role_name) + .issue_capability(CapabilityIssueOptions::default())? + .build_and_execute(&client) + .await? + .output; + + let destroyed = roles + .destroy_capability(issued_for_destroy.capability_id)? + .build_and_execute(&client) + .await? + .output; + + assert_eq!(destroyed.target_key, trail_id); + assert_eq!(destroyed.capability_id, issued_for_destroy.capability_id); + assert_eq!(destroyed.role, role_name.to_string()); + assert_eq!(destroyed.issued_to, None); + assert_eq!(destroyed.valid_from, None); + assert_eq!(destroyed.valid_until, None); + + Ok(()) +} diff --git a/notarization-move/Move.history.json b/notarization-move/Move.history.json index 8f6fee0e..e3d523ac 100644 --- a/notarization-move/Move.history.json +++ b/notarization-move/Move.history.json @@ -5,14 +5,14 @@ "mainnet": "6364aad5" }, "envs": { - "e678123a": [ - "0x0d88bcecde97585d50207a029a85d7ea0bacf73ab741cbaa975a6e279251033a" - ], "6364aad5": [ "0x909ce9dcd9a5e97b7b8884fac8e018fad9dece348bf73837379b8694ff684cf3" ], "2304aa97": [ "0x00412bd469b7f980227c6c574090348239852e43aa07818b315854fdd8a2d25f" + ], + "e678123a": [ + "0x0d88bcecde97585d50207a029a85d7ea0bacf73ab741cbaa975a6e279251033a" ] } } \ No newline at end of file diff --git a/notarization-move/Move.lock b/notarization-move/Move.lock index ad00e684..e466bd75 100644 --- a/notarization-move/Move.lock +++ b/notarization-move/Move.lock @@ -2,19 +2,19 @@ [move] version = 3 -manifest_digest = "8019AAD757782B3104C1350C12F73AA444CDAA9B3E4B40A2468C3DA235715C42" +manifest_digest = "E8F9EAB938F4F4898CB27E88DD059EEA0544D15A08AC9AFC6A0E81D4F3030DAC" deps_digest = "397E6A9F7A624706DBDFEE056CE88391A15876868FD18A88504DA74EB458D697" dependencies = [ { id = "Iota", name = "Iota" }, { id = "IotaSystem", name = "IotaSystem" }, { id = "MoveStdlib", name = "MoveStdlib" }, { id = "Stardust", name = "Stardust" }, - { id = "tf_components", name = "tf_components" }, + { id = "TfComponents", name = "TfComponents" }, ] [[move.package]] id = "Iota" -source = { git = "https://github.com/iotaledger/iota.git", rev = "4698c6723208e052a00c74602d2c8dc0efffe5de", subdir = "crates/iota-framework/packages/iota-framework" } +source = { git = "https://github.com/iotaledger/iota.git", rev = "e694e2ee8f2f9f0b9b03b843a24ff0f7bcff2930", subdir = "crates/iota-framework/packages/iota-framework" } dependencies = [ { id = "MoveStdlib", name = "MoveStdlib" }, @@ -22,7 +22,7 @@ dependencies = [ [[move.package]] id = "IotaSystem" -source = { git = "https://github.com/iotaledger/iota.git", rev = "4698c6723208e052a00c74602d2c8dc0efffe5de", subdir = "crates/iota-framework/packages/iota-system" } +source = { git = "https://github.com/iotaledger/iota.git", rev = "e694e2ee8f2f9f0b9b03b843a24ff0f7bcff2930", subdir = "crates/iota-framework/packages/iota-system" } dependencies = [ { id = "Iota", name = "Iota" }, @@ -31,11 +31,11 @@ dependencies = [ [[move.package]] id = "MoveStdlib" -source = { git = "https://github.com/iotaledger/iota.git", rev = "4698c6723208e052a00c74602d2c8dc0efffe5de", subdir = "crates/iota-framework/packages/move-stdlib" } +source = { git = "https://github.com/iotaledger/iota.git", rev = "e694e2ee8f2f9f0b9b03b843a24ff0f7bcff2930", subdir = "crates/iota-framework/packages/move-stdlib" } [[move.package]] id = "Stardust" -source = { git = "https://github.com/iotaledger/iota.git", rev = "4698c6723208e052a00c74602d2c8dc0efffe5de", subdir = "crates/iota-framework/packages/stardust" } +source = { git = "https://github.com/iotaledger/iota.git", rev = "e694e2ee8f2f9f0b9b03b843a24ff0f7bcff2930", subdir = "crates/iota-framework/packages/stardust" } dependencies = [ { id = "Iota", name = "Iota" }, @@ -43,8 +43,8 @@ dependencies = [ ] [[move.package]] -id = "tf_components" -source = { git = "https://github.com/iotaledger/product-core.git", rev = "feat/role-map", subdir = "components_move" } +id = "TfComponents" +source = { git = "https://github.com/iotaledger/product-core.git", rev = "main", subdir = "components_move" } dependencies = [ { id = "Iota", name = "Iota" }, @@ -54,16 +54,16 @@ dependencies = [ ] [move.toolchain-version] -compiler-version = "1.14.1" +compiler-version = "1.16.2-rc" edition = "2024.beta" flavor = "iota" [env] [env.localnet] -chain-id = "ecc0606a" -original-published-id = "0xfbddb4631d027b2c4f0b4b90c020713d258ed32bdb342b5397f4da71edb7478a" -latest-published-id = "0xfbddb4631d027b2c4f0b4b90c020713d258ed32bdb342b5397f4da71edb7478a" +chain-id = "4991e514" +original-published-id = "0x8d9e2e2f04101e66c778cfeef09a9fdc945b172cb9f550ace1d36b23ac536735" +latest-published-id = "0x8d9e2e2f04101e66c778cfeef09a9fdc945b172cb9f550ace1d36b23ac536735" published-version = "1" [env.devnet] From 3a750e88411c7ed43f69a91f5509cf7f287bf5d8 Mon Sep 17 00:00:00 2001 From: Yasir Date: Mon, 16 Feb 2026 15:29:32 +0300 Subject: [PATCH 057/189] feat: add destroy and revoke functionality for initial admin capabilities - Implemented `destroy_initial_admin_capability` and `revoke_initial_admin_capability` methods in `RolesOps`. - Created new transaction types for managing roles and capabilities, including `CreateRole`, `UpdateRole`, `DeleteRole`, `IssueCapability`, `RevokeCapability`, `DestroyCapability`, `DestroyInitialAdminCapability`, and `RevokeInitialAdminCapability`. - Updated `AuditTrailHandle` to remove unnecessary `records_as` method. - Refactored `RoleMap` structure to improve clarity and maintainability. - Enhanced end-to-end tests to cover new functionality for destroying and revoking initial admin capabilities, ensuring proper event emissions and error handling. --- audit-trail-move/Move.lock | 6 +- audit-trail-move/Move.toml | 2 +- audit-trail-move/sources/audit_trail.move | 44 +- audit-trail-move/sources/permission.move | 2 +- audit-trail-rs/src/core/builder.rs | 5 +- audit-trail-rs/src/core/create/mod.rs | 145 +--- .../src/core/create/transactions.rs | 147 ++++ audit-trail-rs/src/core/records/mod.rs | 220 +----- audit-trail-rs/src/core/records/operations.rs | 29 - .../src/core/records/transactions.rs | 161 +++++ audit-trail-rs/src/core/roles/mod.rs | 573 ++-------------- audit-trail-rs/src/core/roles/operations.rs | 44 ++ audit-trail-rs/src/core/roles/transactions.rs | 630 ++++++++++++++++++ audit-trail-rs/src/core/trail.rs | 5 - audit-trail-rs/src/core/types/role_map.rs | 26 +- .../tests/e2e/audit_trail_creations.rs | 8 +- audit-trail-rs/tests/e2e/records.rs | 2 +- audit-trail-rs/tests/e2e/roles.rs | 133 +++- notarization-move/Move.history.json | 4 +- 19 files changed, 1233 insertions(+), 953 deletions(-) create mode 100644 audit-trail-rs/src/core/create/transactions.rs create mode 100644 audit-trail-rs/src/core/records/transactions.rs create mode 100644 audit-trail-rs/src/core/roles/transactions.rs diff --git a/audit-trail-move/Move.lock b/audit-trail-move/Move.lock index 74e49424..bf0606c8 100644 --- a/audit-trail-move/Move.lock +++ b/audit-trail-move/Move.lock @@ -2,7 +2,7 @@ [move] version = 3 -manifest_digest = "7FAADB221F87213D4EB687B179D501CAD603866603B43FD12199C657A3C68FE9" +manifest_digest = "F2B4163DBAC14E8E7B58437714EE2902F08812C1A053496961F3557F0DD54E86" deps_digest = "397E6A9F7A624706DBDFEE056CE88391A15876868FD18A88504DA74EB458D697" dependencies = [ { id = "Iota", name = "Iota" }, @@ -44,7 +44,7 @@ dependencies = [ [[move.package]] id = "TfComponents" -source = { git = "https://github.com/iotaledger/product-core.git", rev = "feat/role-map", subdir = "components_move" } +source = { git = "https://github.com/iotaledger/product-core.git", rev = "feat/emit-events-for-capabilities", subdir = "components_move" } dependencies = [ { id = "Iota", name = "Iota" }, @@ -54,7 +54,7 @@ dependencies = [ ] [move.toolchain-version] -compiler-version = "1.16.2-rc" +compiler-version = "1.16.2" edition = "2024.beta" flavor = "iota" diff --git a/audit-trail-move/Move.toml b/audit-trail-move/Move.toml index 61ee5a67..3f851808 100644 --- a/audit-trail-move/Move.toml +++ b/audit-trail-move/Move.toml @@ -3,7 +3,7 @@ name = "audit_trail" edition = "2024.beta" [dependencies] -TfComponents = { git = "https://github.com/iotaledger/product-core.git", subdir = "components_move", rev = "feat/role-map" } +TfComponents = { git = "https://github.com/iotaledger/product-core.git", subdir = "components_move", rev = "feat/emit-events-for-capabilities" } [addresses] audit_trail = "0x0" diff --git a/audit-trail-move/sources/audit_trail.move b/audit-trail-move/sources/audit_trail.move index 5773397f..8425775d 100644 --- a/audit-trail-move/sources/audit_trail.move +++ b/audit-trail-move/sources/audit_trail.move @@ -248,7 +248,7 @@ entry fun migrate( trail: &mut AuditTrail, cap: &Capability, clock: &Clock, - ctx: &TxContext, + ctx: &mut TxContext, ) { assert!(trail.version < PACKAGE_VERSION, EPackageVersionMismatch); assert!( @@ -458,7 +458,7 @@ public fun create_role( role: String, permissions: VecSet, clock: &Clock, - ctx: &TxContext, + ctx: &mut TxContext, ) { assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); role_map::create_role(trail.roles_mut(), cap, role, permissions, clock, ctx); @@ -478,7 +478,7 @@ public fun update_role_permissions( role: String, new_permissions: VecSet, clock: &Clock, - ctx: &TxContext, + ctx: &mut TxContext, ) { assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); role_map::update_role_permissions(trail.roles_mut(), cap, &role, new_permissions, clock, ctx); @@ -497,7 +497,7 @@ public fun delete_role( cap: &Capability, role: String, clock: &Clock, - ctx: &TxContext, + ctx: &mut TxContext, ) { assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); role_map::delete_role(trail.roles_mut(), cap, &role, clock, ctx); @@ -550,7 +550,7 @@ public fun revoke_capability( cap: &Capability, capability_id: ID, clock: &Clock, - ctx: &TxContext, + ctx: &mut TxContext, ) { assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); role_map::revoke_capability(trail.roles_mut(), cap, capability_id, clock, ctx); @@ -564,7 +564,7 @@ public fun destroy_capability( cap: &Capability, cap_to_destroy: Capability, clock: &Clock, - ctx: &TxContext, + ctx: &mut TxContext, ) { assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); assert!( @@ -581,6 +581,38 @@ public fun destroy_capability( role_map::destroy_capability(trail.roles_mut(), cap_to_destroy); } +/// Destroys an initial admin capability. +/// +/// Self-service: the owner passes in their own initial admin capability to destroy it. +/// No additional authorization is required. +/// +/// WARNING: If all initial admin capabilities are destroyed, the trail will be permanently +/// sealed with no admin access possible. +public fun destroy_initial_admin_capability( + trail: &mut AuditTrail, + cap_to_destroy: Capability, +) { + assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); + role_map::destroy_initial_admin_capability(trail.roles_mut(), cap_to_destroy); +} + +/// Revokes an initial admin capability by ID. +/// +/// Requires a capability with `RevokeCapabilities` permission. +/// +/// WARNING: If all initial admin capabilities are revoked, the trail will be permanently +/// sealed with no admin access possible. +public fun revoke_initial_admin_capability( + trail: &mut AuditTrail, + cap: &Capability, + capability_id: ID, + clock: &Clock, + ctx: &mut TxContext, +) { + assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); + role_map::revoke_initial_admin_capability(trail.roles_mut(), cap, capability_id, clock, ctx); +} + // ===== Trail Query Functions ===== /// Get the total number of records currently in the trail diff --git a/audit-trail-move/sources/permission.move b/audit-trail-move/sources/permission.move index 04b324e5..d98dbcea 100644 --- a/audit-trail-move/sources/permission.move +++ b/audit-trail-move/sources/permission.move @@ -17,7 +17,7 @@ public enum Permission has copy, drop, store { /// Delete records from the trail DeleteRecord, /// Correct existing records in the trail - CorrectRecord, // TODO: Clarify if needed for MVP + CorrectRecord, // --- Locking Config - Proposed role: `LockingAdmin` --- /// Update the whole locking configuration UpdateLockingConfig, diff --git a/audit-trail-rs/src/core/builder.rs b/audit-trail-rs/src/core/builder.rs index 5df8ef14..fede0bd2 100644 --- a/audit-trail-rs/src/core/builder.rs +++ b/audit-trail-rs/src/core/builder.rs @@ -8,7 +8,6 @@ use product_common::transaction::transaction_builder::TransactionBuilder; use super::types::{Data, ImmutableMetadata, LockingConfig}; use crate::core::create::CreateTrail; -use crate::error::Error; /// Builder for creating an audit trail. #[derive(Debug, Clone, Default)] @@ -63,7 +62,7 @@ impl AuditTrailBuilder { } /// Finalizes the builder and creates a transaction builder. - pub fn finish(self) -> Result, Error> { - Ok(TransactionBuilder::new(CreateTrail::new(self))) + pub fn finish(self) -> TransactionBuilder { + TransactionBuilder::new(CreateTrail::new(self)) } } diff --git a/audit-trail-rs/src/core/create/mod.rs b/audit-trail-rs/src/core/create/mod.rs index 4d8058de..7365c88c 100644 --- a/audit-trail-rs/src/core/create/mod.rs +++ b/audit-trail-rs/src/core/create/mod.rs @@ -1,148 +1,7 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use async_trait::async_trait; -use iota_interaction::IotaClientTrait; -use iota_interaction::OptionalSync; -use iota_interaction::rpc_types::{ - IotaData as _, IotaObjectDataOptions, IotaTransactionBlockEffects, IotaTransactionBlockEvents, -}; -use iota_interaction::types::base_types::ObjectID; -use iota_interaction::types::transaction::ProgrammableTransaction; -use iota_sdk::types::base_types::IotaAddress; -use product_common::core_client::CoreClientReadOnly; -use product_common::transaction::transaction_builder::Transaction; -use tokio::sync::OnceCell; - -use crate::core::builder::AuditTrailBuilder; -use crate::core::types::{AuditTrailCreated, Event, OnChainAuditTrail}; -use crate::error::Error; - mod operations; -use self::operations::CreateOps; - -/// Output of a create trail transaction. -#[derive(Debug, Clone)] -pub struct TrailCreated { - pub trail_id: ObjectID, - pub creator: IotaAddress, - pub timestamp: u64, -} - -impl TrailCreated { - pub async fn load_on_chain(&self, client: &C) -> Result - where - C: CoreClientReadOnly + OptionalSync, - { - let data = client - .client_adapter() - .read_api() - .get_object_with_options(self.trail_id, IotaObjectDataOptions::bcs_lossless()) - .await - .map_err(|e| Error::UnexpectedApiResponse(format!("failed to fetch trail {} object; {e}", self.trail_id)))? - .data - .ok_or_else(|| Error::UnexpectedApiResponse(format!("trail {} data not found", self.trail_id)))?; - - data.bcs - .ok_or_else(|| Error::UnexpectedApiResponse(format!("trail {} missing bcs object content", self.trail_id)))? - .try_into_move() - .ok_or_else(|| { - Error::UnexpectedApiResponse(format!("trail {} bcs content is not a move object", self.trail_id)) - })? - .deserialize() - .map_err(|e| { - Error::UnexpectedApiResponse(format!("failed to decode trail {} bcs data; {e}", self.trail_id)) - }) - } -} - -/// A transaction that creates a new audit trail. -#[derive(Debug, Clone)] -pub struct CreateTrail { - builder: AuditTrailBuilder, - cached_ptb: OnceCell, -} - -impl CreateTrail { - /// Creates a new [`CreateTrail`] instance. - pub fn new(builder: AuditTrailBuilder) -> Self { - Self { - builder, - cached_ptb: OnceCell::new(), - } - } - - async fn make_ptb(&self, client: &C) -> Result - where - C: CoreClientReadOnly + OptionalSync, - { - let AuditTrailBuilder { - admin, - initial_data, - initial_record_metadata, - locking_config, - trail_metadata, - updatable_metadata, - } = self.builder.clone(); - - let admin = admin.ok_or_else(|| { - Error::InvalidArgument( - "admin address is required; use `client.create_trail()` with signer or call `with_admin(...)`" - .to_string(), - ) - })?; - - CreateOps::create_trail( - client.package_id(), - admin, - initial_data, - initial_record_metadata, - locking_config, - trail_metadata, - updatable_metadata, - ) - } -} - -#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] -#[cfg_attr(feature = "send-sync", async_trait)] -impl Transaction for CreateTrail { - type Error = Error; - type Output = TrailCreated; - - async fn build_programmable_transaction(&self, client: &C) -> Result - where - C: CoreClientReadOnly + OptionalSync, - { - self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() - } - - async fn apply_with_events( - mut self, - _: &mut IotaTransactionBlockEffects, - events: &mut IotaTransactionBlockEvents, - _: &C, - ) -> Result - where - C: CoreClientReadOnly + OptionalSync, - { - let event = events - .data - .iter() - .find_map(|data| serde_json::from_value::>(data.parsed_json.clone()).ok()) - .ok_or_else(|| Error::UnexpectedApiResponse("AuditTrailCreated event not found".to_string()))?; - - Ok(TrailCreated { - trail_id: event.data.trail_id, - creator: event.data.creator, - timestamp: event.data.timestamp, - }) - } +mod transactions; - async fn apply(mut self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result - where - C: CoreClientReadOnly + OptionalSync, - { - unreachable!() - } -} +pub use transactions::{CreateTrail, TrailCreated}; diff --git a/audit-trail-rs/src/core/create/transactions.rs b/audit-trail-rs/src/core/create/transactions.rs new file mode 100644 index 00000000..38bb261c --- /dev/null +++ b/audit-trail-rs/src/core/create/transactions.rs @@ -0,0 +1,147 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use async_trait::async_trait; +use iota_interaction::IotaClientTrait; +use iota_interaction::OptionalSync; +use iota_interaction::rpc_types::{ + IotaData as _, IotaObjectDataOptions, IotaTransactionBlockEffects, IotaTransactionBlockEvents, +}; +use iota_interaction::types::base_types::ObjectID; +use iota_interaction::types::transaction::ProgrammableTransaction; +use iota_sdk::types::base_types::IotaAddress; +use product_common::core_client::CoreClientReadOnly; +use product_common::transaction::transaction_builder::Transaction; +use tokio::sync::OnceCell; + +use crate::core::builder::AuditTrailBuilder; +use crate::core::types::{AuditTrailCreated, Event, OnChainAuditTrail}; +use crate::error::Error; + +use super::operations::CreateOps; + +/// Output of a create trail transaction. +#[derive(Debug, Clone)] +pub struct TrailCreated { + pub trail_id: ObjectID, + pub creator: IotaAddress, + pub timestamp: u64, +} + +impl TrailCreated { + pub async fn load_on_chain(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + let data = client + .client_adapter() + .read_api() + .get_object_with_options(self.trail_id, IotaObjectDataOptions::bcs_lossless()) + .await + .map_err(|e| Error::UnexpectedApiResponse(format!("failed to fetch trail {} object; {e}", self.trail_id)))? + .data + .ok_or_else(|| Error::UnexpectedApiResponse(format!("trail {} data not found", self.trail_id)))?; + + data.bcs + .ok_or_else(|| Error::UnexpectedApiResponse(format!("trail {} missing bcs object content", self.trail_id)))? + .try_into_move() + .ok_or_else(|| { + Error::UnexpectedApiResponse(format!("trail {} bcs content is not a move object", self.trail_id)) + })? + .deserialize() + .map_err(|e| { + Error::UnexpectedApiResponse(format!("failed to decode trail {} bcs data; {e}", self.trail_id)) + }) + } +} + +/// A transaction that creates a new audit trail. +#[derive(Debug, Clone)] +pub struct CreateTrail { + builder: AuditTrailBuilder, + cached_ptb: OnceCell, +} + +impl CreateTrail { + /// Creates a new [`CreateTrail`] instance. + pub fn new(builder: AuditTrailBuilder) -> Self { + Self { + builder, + cached_ptb: OnceCell::new(), + } + } + + async fn make_ptb(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + let AuditTrailBuilder { + admin, + initial_data, + initial_record_metadata, + locking_config, + trail_metadata, + updatable_metadata, + } = self.builder.clone(); + + let admin = admin.ok_or_else(|| { + Error::InvalidArgument( + "admin address is required; use `client.create_trail()` with signer or call `with_admin(...)`" + .to_string(), + ) + })?; + + CreateOps::create_trail( + client.package_id(), + admin, + initial_data, + initial_record_metadata, + locking_config, + trail_metadata, + updatable_metadata, + ) + } +} + +#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] +#[cfg_attr(feature = "send-sync", async_trait)] +impl Transaction for CreateTrail { + type Error = Error; + type Output = TrailCreated; + + async fn build_programmable_transaction(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() + } + + async fn apply_with_events( + mut self, + _: &mut IotaTransactionBlockEffects, + events: &mut IotaTransactionBlockEvents, + _: &C, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + let event = events + .data + .iter() + .find_map(|data| serde_json::from_value::>(data.parsed_json.clone()).ok()) + .ok_or_else(|| Error::UnexpectedApiResponse("AuditTrailCreated event not found".to_string()))?; + + Ok(TrailCreated { + trail_id: event.data.trail_id, + creator: event.data.creator, + timestamp: event.data.timestamp, + }) + } + + async fn apply(mut self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + unreachable!() + } +} diff --git a/audit-trail-rs/src/core/records/mod.rs b/audit-trail-rs/src/core/records/mod.rs index 395b8468..ef8f97fe 100644 --- a/audit-trail-rs/src/core/records/mod.rs +++ b/audit-trail-rs/src/core/records/mod.rs @@ -1,29 +1,28 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use async_trait::async_trait; use iota_interaction::move_types::annotated_value::MoveValue; use iota_interaction::rpc_types::IotaMoveValue; -use iota_interaction::rpc_types::{IotaTransactionBlockEffects, IotaTransactionBlockEvents}; use iota_interaction::types::TypeTag; -use iota_interaction::types::base_types::{IotaAddress, ObjectID}; +use iota_interaction::types::base_types::ObjectID; use iota_interaction::types::collection_types::{LinkedTable, LinkedTableNode}; use iota_interaction::types::dynamic_field::DynamicFieldName; -use iota_interaction::types::transaction::ProgrammableTransaction; use iota_interaction::{IotaClientTrait, IotaKeySignature, OptionalSync}; use product_common::core_client::{CoreClient, CoreClientReadOnly}; -use product_common::transaction::transaction_builder::{Transaction, TransactionBuilder}; +use product_common::transaction::transaction_builder::TransactionBuilder; use secret_storage::Signer; use serde::{Deserialize, de::DeserializeOwned}; use std::collections::HashMap; -use tokio::sync::OnceCell; use crate::core::trail::{AuditTrailFull, AuditTrailReadOnly}; -use crate::core::types::{Data, Event, OnChainAuditTrail, PaginatedRecord, Record, RecordAdded, RecordDeleted}; +use crate::core::types::{Data, OnChainAuditTrail, PaginatedRecord, Record}; use crate::error::Error; mod operations; +mod transactions; + use self::operations::RecordsOps; +pub use transactions::{AddRecord, DeleteRecord}; #[derive(Debug, Clone)] pub struct TrailRecords<'a, C, D = Data> { @@ -50,57 +49,32 @@ impl<'a, C, D> TrailRecords<'a, C, D> { self.client.execute_read_only_transaction(tx).await } - pub async fn list(&self) -> Result>, Error> - where - C: AuditTrailReadOnly, - D: DeserializeOwned, - { - let first = self.first_sequence().await?; - let last = self.last_sequence().await?; - - let Some(first_seq) = first else { - return Ok(Vec::new()); - }; - let Some(last_seq) = last else { - return Ok(Vec::new()); - }; - - let mut records = Vec::new(); - for seq in first_seq..=last_seq { - if self.has_record(seq).await? { - records.push(self.get(seq).await?); - } - } - - Ok(records) - } - - pub fn add(&self, data: D, metadata: Option) -> Result, Error> + pub fn add(&self, data: D, metadata: Option) -> TransactionBuilder where C: AuditTrailFull + CoreClient, S: Signer + OptionalSync, D: Into, { let owner = self.client.sender_address(); - Ok(TransactionBuilder::new(AddRecord::new( + TransactionBuilder::new(AddRecord::new( self.trail_id, owner, data.into(), metadata, - ))) + )) } - pub fn delete(&self, sequence_number: u64) -> Result, Error> + pub fn delete(&self, sequence_number: u64) -> TransactionBuilder where C: AuditTrailFull + CoreClient, S: Signer + OptionalSync, { let owner = self.client.sender_address(); - Ok(TransactionBuilder::new(DeleteRecord::new( + TransactionBuilder::new(DeleteRecord::new( self.trail_id, owner, sequence_number, - ))) + )) } pub async fn correct(&self, _replaces: Vec, _data: D, _metadata: Option) -> Result<(), Error> @@ -110,30 +84,6 @@ impl<'a, C, D> TrailRecords<'a, C, D> { Err(Error::NotImplemented("TrailRecords::correct")) } - async fn has_record(&self, sequence_number: u64) -> Result - where - C: AuditTrailReadOnly, - { - let tx = RecordsOps::has_record(self.client, self.trail_id, sequence_number).await?; - self.client.execute_read_only_transaction(tx).await - } - - async fn first_sequence(&self) -> Result, Error> - where - C: AuditTrailReadOnly, - { - let tx = RecordsOps::first_sequence(self.client, self.trail_id).await?; - self.client.execute_read_only_transaction(tx).await - } - - async fn last_sequence(&self) -> Result, Error> - where - C: AuditTrailReadOnly, - { - let tx = RecordsOps::last_sequence(self.client, self.trail_id).await?; - self.client.execute_read_only_transaction(tx).await - } - pub async fn record_count(&self) -> Result where C: AuditTrailReadOnly, @@ -142,10 +92,11 @@ impl<'a, C, D> TrailRecords<'a, C, D> { self.client.execute_read_only_transaction(tx).await } - /// List all linked-table records into a [`HashMap`]. + /// List all records into a [`HashMap`]. /// /// This traverses the full on-chain linked table and can be expensive for large trails. - pub async fn list_all(&self) -> Result>, Error> + /// For paginated access, use [`list_page`](Self::list_page). + pub async fn list(&self) -> Result>, Error> where C: AuditTrailReadOnly, D: DeserializeOwned, @@ -198,146 +149,7 @@ impl<'a, C, D> TrailRecords<'a, C, D> { } } -#[derive(Debug, Clone)] -pub struct AddRecord { - pub trail_id: ObjectID, - pub owner: IotaAddress, - pub data: Data, - pub metadata: Option, - cached_ptb: OnceCell, -} - -impl AddRecord { - pub fn new(trail_id: ObjectID, owner: IotaAddress, data: Data, metadata: Option) -> Self { - Self { - trail_id, - owner, - data, - metadata, - cached_ptb: OnceCell::new(), - } - } - - async fn make_ptb(&self, client: &C) -> Result - where - C: CoreClientReadOnly + OptionalSync, - { - RecordsOps::add_record( - client, - self.trail_id, - self.owner, - self.data.clone(), - self.metadata.clone(), - ) - .await - } -} - -#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] -#[cfg_attr(feature = "send-sync", async_trait)] -impl Transaction for AddRecord { - type Error = Error; - type Output = RecordAdded; - - async fn build_programmable_transaction(&self, client: &C) -> Result - where - C: CoreClientReadOnly + OptionalSync, - { - self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() - } - - async fn apply_with_events( - mut self, - _: &mut IotaTransactionBlockEffects, - events: &mut IotaTransactionBlockEvents, - _: &C, - ) -> Result - where - C: CoreClientReadOnly + OptionalSync, - { - for data in &events.data { - if let Ok(event) = serde_json::from_value::>(data.parsed_json.clone()) { - return Ok(event.data); - } - } - - Err(Error::UnexpectedApiResponse("RecordAdded event not found".to_string())) - } - - async fn apply(mut self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result - where - C: CoreClientReadOnly + OptionalSync, - { - unreachable!() - } -} - -#[derive(Debug, Clone)] -pub struct DeleteRecord { - pub trail_id: ObjectID, - pub owner: IotaAddress, - pub sequence_number: u64, - cached_ptb: OnceCell, -} - -impl DeleteRecord { - pub fn new(trail_id: ObjectID, owner: IotaAddress, sequence_number: u64) -> Self { - Self { - trail_id, - owner, - sequence_number, - cached_ptb: OnceCell::new(), - } - } - - async fn make_ptb(&self, client: &C) -> Result - where - C: CoreClientReadOnly + OptionalSync, - { - RecordsOps::delete_record(client, self.trail_id, self.owner, self.sequence_number).await - } -} - -#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] -#[cfg_attr(feature = "send-sync", async_trait)] -impl Transaction for DeleteRecord { - type Error = Error; - type Output = RecordDeleted; - - async fn build_programmable_transaction(&self, client: &C) -> Result - where - C: CoreClientReadOnly + OptionalSync, - { - self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() - } - - async fn apply_with_events( - mut self, - _: &mut IotaTransactionBlockEffects, - events: &mut IotaTransactionBlockEvents, - _: &C, - ) -> Result - where - C: CoreClientReadOnly + OptionalSync, - { - for data in &events.data { - if let Ok(event) = serde_json::from_value::>(data.parsed_json.clone()) { - return Ok(event.data); - } - } - - Err(Error::UnexpectedApiResponse( - "RecordDeleted event not found".to_string(), - )) - } - - async fn apply(mut self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result - where - C: CoreClientReadOnly + OptionalSync, - { - unreachable!() - } -} +// ===== Linked-table traversal helpers ===== async fn list_linked_table_page( client: &C, diff --git a/audit-trail-rs/src/core/records/operations.rs b/audit-trail-rs/src/core/records/operations.rs index 0283f0f0..f353ab65 100644 --- a/audit-trail-rs/src/core/records/operations.rs +++ b/audit-trail-rs/src/core/records/operations.rs @@ -67,39 +67,10 @@ impl RecordsOps { .await } - pub(super) async fn has_record( - client: &C, - trail_id: ObjectID, - sequence_number: u64, - ) -> Result - where - C: CoreClientReadOnly + OptionalSync, - { - operations::build_read_only_transaction(client, trail_id, "has_record", |ptb| { - let seq = utils::ptb_pure(ptb, "sequence_number", sequence_number)?; - Ok(vec![seq]) - }) - .await - } - pub(super) async fn record_count(client: &C, trail_id: ObjectID) -> Result where C: CoreClientReadOnly + OptionalSync, { operations::build_read_only_transaction(client, trail_id, "record_count", |_| Ok(vec![])).await } - - pub(super) async fn first_sequence(client: &C, trail_id: ObjectID) -> Result - where - C: CoreClientReadOnly + OptionalSync, - { - operations::build_read_only_transaction(client, trail_id, "first_sequence", |_| Ok(vec![])).await - } - - pub(super) async fn last_sequence(client: &C, trail_id: ObjectID) -> Result - where - C: CoreClientReadOnly + OptionalSync, - { - operations::build_read_only_transaction(client, trail_id, "last_sequence", |_| Ok(vec![])).await - } } diff --git a/audit-trail-rs/src/core/records/transactions.rs b/audit-trail-rs/src/core/records/transactions.rs new file mode 100644 index 00000000..3b7e3248 --- /dev/null +++ b/audit-trail-rs/src/core/records/transactions.rs @@ -0,0 +1,161 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use async_trait::async_trait; +use iota_interaction::OptionalSync; +use iota_interaction::rpc_types::{IotaTransactionBlockEffects, IotaTransactionBlockEvents}; +use iota_interaction::types::base_types::{IotaAddress, ObjectID}; +use iota_interaction::types::transaction::ProgrammableTransaction; +use product_common::core_client::CoreClientReadOnly; +use product_common::transaction::transaction_builder::Transaction; +use tokio::sync::OnceCell; + +use crate::core::types::{Data, Event, RecordAdded, RecordDeleted}; +use crate::error::Error; + +use super::operations::RecordsOps; + +// ===== AddRecord ===== + +#[derive(Debug, Clone)] +pub struct AddRecord { + pub trail_id: ObjectID, + pub owner: IotaAddress, + pub data: Data, + pub metadata: Option, + cached_ptb: OnceCell, +} + +impl AddRecord { + pub fn new(trail_id: ObjectID, owner: IotaAddress, data: Data, metadata: Option) -> Self { + Self { + trail_id, + owner, + data, + metadata, + cached_ptb: OnceCell::new(), + } + } + + async fn make_ptb(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + RecordsOps::add_record( + client, + self.trail_id, + self.owner, + self.data.clone(), + self.metadata.clone(), + ) + .await + } +} + +#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] +#[cfg_attr(feature = "send-sync", async_trait)] +impl Transaction for AddRecord { + type Error = Error; + type Output = RecordAdded; + + async fn build_programmable_transaction(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() + } + + async fn apply_with_events( + mut self, + _: &mut IotaTransactionBlockEffects, + events: &mut IotaTransactionBlockEvents, + _: &C, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + for data in &events.data { + if let Ok(event) = serde_json::from_value::>(data.parsed_json.clone()) { + return Ok(event.data); + } + } + + Err(Error::UnexpectedApiResponse("RecordAdded event not found".to_string())) + } + + async fn apply(mut self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + unreachable!() + } +} + +// ===== DeleteRecord ===== + +#[derive(Debug, Clone)] +pub struct DeleteRecord { + pub trail_id: ObjectID, + pub owner: IotaAddress, + pub sequence_number: u64, + cached_ptb: OnceCell, +} + +impl DeleteRecord { + pub fn new(trail_id: ObjectID, owner: IotaAddress, sequence_number: u64) -> Self { + Self { + trail_id, + owner, + sequence_number, + cached_ptb: OnceCell::new(), + } + } + + async fn make_ptb(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + RecordsOps::delete_record(client, self.trail_id, self.owner, self.sequence_number).await + } +} + +#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] +#[cfg_attr(feature = "send-sync", async_trait)] +impl Transaction for DeleteRecord { + type Error = Error; + type Output = RecordDeleted; + + async fn build_programmable_transaction(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() + } + + async fn apply_with_events( + mut self, + _: &mut IotaTransactionBlockEffects, + events: &mut IotaTransactionBlockEvents, + _: &C, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + for data in &events.data { + if let Ok(event) = serde_json::from_value::>(data.parsed_json.clone()) { + return Ok(event.data); + } + } + + Err(Error::UnexpectedApiResponse( + "RecordDeleted event not found".to_string(), + )) + } + + async fn apply(mut self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + unreachable!() + } +} diff --git a/audit-trail-rs/src/core/roles/mod.rs b/audit-trail-rs/src/core/roles/mod.rs index 1f83a6e6..235e7b90 100644 --- a/audit-trail-rs/src/core/roles/mod.rs +++ b/audit-trail-rs/src/core/roles/mod.rs @@ -1,25 +1,22 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use async_trait::async_trait; -use iota_interaction::types::base_types::{IotaAddress, ObjectID}; -use iota_interaction::types::transaction::ProgrammableTransaction; +use iota_interaction::types::base_types::ObjectID; use iota_interaction::{IotaKeySignature, OptionalSync}; -use product_common::core_client::{CoreClient, CoreClientReadOnly}; -use product_common::transaction::transaction_builder::{Transaction, TransactionBuilder}; +use product_common::core_client::CoreClient; +use product_common::transaction::transaction_builder::TransactionBuilder; use secret_storage::Signer; -use tokio::sync::OnceCell; use crate::core::trail::AuditTrailFull; -use crate::core::types::{ - CapabilityDestroyed, CapabilityIssueOptions, CapabilityIssued, CapabilityRevoked, Event, PermissionSet, RoleCreated, - RoleDeleted, RoleUpdated, -}; -use crate::error::Error; +use crate::core::types::{CapabilityIssueOptions, PermissionSet}; mod operations; -use self::operations::RolesOps; -use iota_interaction::rpc_types::{IotaTransactionBlockEffects, IotaTransactionBlockEvents}; +mod transactions; + +pub use transactions::{ + CreateRole, DeleteRole, DestroyCapability, DestroyInitialAdminCapability, IssueCapability, RevokeCapability, + RevokeInitialAdminCapability, UpdateRole, +}; #[derive(Debug, Clone)] pub struct TrailRoles<'a, C> { @@ -37,84 +34,64 @@ impl<'a, C> TrailRoles<'a, C> { RoleHandle::new(self.client, self.trail_id, name.into()) } - /// Creates a new role with the provided permissions. - pub fn create( - &self, - name: impl Into, - permissions: PermissionSet, - ) -> Result, Error> - where - C: AuditTrailFull + CoreClient, - S: Signer + OptionalSync, - { - let owner = self.client.sender_address(); - Ok(TransactionBuilder::new(CreateRole::new( - self.trail_id, - owner, - name.into(), - permissions, - ))) - } - - /// Updates permissions for an existing role. - pub fn update( - &self, - name: impl Into, - permissions: PermissionSet, - ) -> Result, Error> + /// Revokes an issued capability. + pub fn revoke_capability(&self, capability_id: ObjectID) -> TransactionBuilder where C: AuditTrailFull + CoreClient, S: Signer + OptionalSync, { let owner = self.client.sender_address(); - Ok(TransactionBuilder::new(UpdateRole::new( + TransactionBuilder::new(RevokeCapability::new( self.trail_id, owner, - name.into(), - permissions, - ))) + capability_id, + )) } - /// Deletes an existing role. - pub fn delete(&self, name: impl Into) -> Result, Error> + /// Destroys a capability object. + pub fn destroy_capability(&self, capability_id: ObjectID) -> TransactionBuilder where C: AuditTrailFull + CoreClient, S: Signer + OptionalSync, { let owner = self.client.sender_address(); - Ok(TransactionBuilder::new(DeleteRole::new( + TransactionBuilder::new(DestroyCapability::new( self.trail_id, owner, - name.into(), - ))) + capability_id, + )) } - /// Revokes an issued capability. - pub fn revoke_capability(&self, capability_id: ObjectID) -> Result, Error> + /// Destroys an initial admin capability (self-service, no auth cap required). + pub fn destroy_initial_admin_capability( + &self, + capability_id: ObjectID, + ) -> TransactionBuilder where C: AuditTrailFull + CoreClient, S: Signer + OptionalSync, { - let owner = self.client.sender_address(); - Ok(TransactionBuilder::new(RevokeCapability::new( + TransactionBuilder::new(DestroyInitialAdminCapability::new( self.trail_id, - owner, capability_id, - ))) + )) } - /// Destroys a capability object. - pub fn destroy_capability(&self, capability_id: ObjectID) -> Result, Error> + /// Revokes an initial admin capability by ID. + pub fn revoke_initial_admin_capability( + &self, + capability_id: ObjectID, + ) -> TransactionBuilder where C: AuditTrailFull + CoreClient, S: Signer + OptionalSync, { let owner = self.client.sender_address(); - Ok(TransactionBuilder::new(DestroyCapability::new( + TransactionBuilder::new(RevokeInitialAdminCapability::new( self.trail_id, owner, capability_id, - ))) + )) } } @@ -134,501 +111,65 @@ impl<'a, C> RoleHandle<'a, C> { &self.name } - /// Issues a capability for this role using optional restrictions. - pub fn issue_capability( - &self, - options: CapabilityIssueOptions, - ) -> Result, Error> + /// Creates this role with the provided permissions. + pub fn create(&self, permissions: PermissionSet) -> TransactionBuilder where C: AuditTrailFull + CoreClient, S: Signer + OptionalSync, { let owner = self.client.sender_address(); - Ok(TransactionBuilder::new(IssueCapability::new( + TransactionBuilder::new(CreateRole::new( self.trail_id, owner, self.name.clone(), - options, - ))) + permissions, + )) } - /// Updates permissions for this role. - pub fn update_permissions(&self, permissions: PermissionSet) -> Result, Error> + /// Issues a capability for this role using optional restrictions. + pub fn issue_capability( + &self, + options: CapabilityIssueOptions, + ) -> TransactionBuilder where C: AuditTrailFull + CoreClient, S: Signer + OptionalSync, { let owner = self.client.sender_address(); - Ok(TransactionBuilder::new(UpdateRole::new( + TransactionBuilder::new(IssueCapability::new( self.trail_id, owner, self.name.clone(), - permissions, - ))) + options, + )) } - /// Deletes this role. - pub fn delete(&self) -> Result, Error> + /// Updates permissions for this role. + pub fn update_permissions(&self, permissions: PermissionSet) -> TransactionBuilder where C: AuditTrailFull + CoreClient, S: Signer + OptionalSync, { let owner = self.client.sender_address(); - Ok(TransactionBuilder::new(DeleteRole::new( + TransactionBuilder::new(UpdateRole::new( self.trail_id, owner, self.name.clone(), - ))) - } -} - -#[derive(Debug, Clone)] -pub struct CreateRole { - trail_id: ObjectID, - owner: IotaAddress, - name: String, - permissions: PermissionSet, - cached_ptb: OnceCell, -} - -impl CreateRole { - pub fn new(trail_id: ObjectID, owner: IotaAddress, name: String, permissions: PermissionSet) -> Self { - Self { - trail_id, - owner, - name, permissions, - cached_ptb: OnceCell::new(), - } - } - - async fn make_ptb(&self, client: &C) -> Result - where - C: CoreClientReadOnly + OptionalSync, - { - RolesOps::create_role( - client, - self.trail_id, - self.owner, - self.name.clone(), - self.permissions.clone(), - ) - .await - } -} - -#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] -#[cfg_attr(feature = "send-sync", async_trait)] -impl Transaction for CreateRole { - type Error = Error; - type Output = RoleCreated; - - async fn build_programmable_transaction(&self, client: &C) -> Result - where - C: CoreClientReadOnly + OptionalSync, - { - self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() - } - - async fn apply_with_events( - mut self, - _: &mut IotaTransactionBlockEffects, - events: &mut IotaTransactionBlockEvents, - _: &C, - ) -> Result - where - C: CoreClientReadOnly + OptionalSync, - { - for data in &events.data { - if let Ok(event) = serde_json::from_value::>(data.parsed_json.clone()) { - return Ok(event.data); - } - } - - Err(Error::UnexpectedApiResponse("RoleCreated event not found".to_string())) - } - - async fn apply(mut self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result - where - C: CoreClientReadOnly + OptionalSync, - { - Err(Error::UnexpectedApiResponse( - "RoleCreated output requires transaction events".to_string(), - )) - } -} - -#[derive(Debug, Clone)] -pub struct UpdateRole { - trail_id: ObjectID, - owner: IotaAddress, - name: String, - permissions: PermissionSet, - cached_ptb: OnceCell, -} - -impl UpdateRole { - pub fn new(trail_id: ObjectID, owner: IotaAddress, name: String, permissions: PermissionSet) -> Self { - Self { - trail_id, - owner, - name, - permissions, - cached_ptb: OnceCell::new(), - } - } - - async fn make_ptb(&self, client: &C) -> Result - where - C: CoreClientReadOnly + OptionalSync, - { - RolesOps::update_role( - client, - self.trail_id, - self.owner, - self.name.clone(), - self.permissions.clone(), - ) - .await - } -} - -#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] -#[cfg_attr(feature = "send-sync", async_trait)] -impl Transaction for UpdateRole { - type Error = Error; - type Output = RoleUpdated; - - async fn build_programmable_transaction(&self, client: &C) -> Result - where - C: CoreClientReadOnly + OptionalSync, - { - self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() - } - - async fn apply_with_events( - mut self, - _: &mut IotaTransactionBlockEffects, - events: &mut IotaTransactionBlockEvents, - _: &C, - ) -> Result - where - C: CoreClientReadOnly + OptionalSync, - { - for data in &events.data { - if let Ok(event) = serde_json::from_value::>(data.parsed_json.clone()) { - return Ok(event.data); - } - } - - Err(Error::UnexpectedApiResponse("RoleUpdated event not found".to_string())) - } - - async fn apply(mut self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result - where - C: CoreClientReadOnly + OptionalSync, - { - Err(Error::UnexpectedApiResponse( - "RoleUpdated output requires transaction events".to_string(), - )) - } -} - -#[derive(Debug, Clone)] -pub struct DeleteRole { - trail_id: ObjectID, - owner: IotaAddress, - name: String, - cached_ptb: OnceCell, -} - -impl DeleteRole { - pub fn new(trail_id: ObjectID, owner: IotaAddress, name: String) -> Self { - Self { - trail_id, - owner, - name, - cached_ptb: OnceCell::new(), - } - } - - async fn make_ptb(&self, client: &C) -> Result - where - C: CoreClientReadOnly + OptionalSync, - { - RolesOps::delete_role(client, self.trail_id, self.owner, self.name.clone()).await - } -} - -#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] -#[cfg_attr(feature = "send-sync", async_trait)] -impl Transaction for DeleteRole { - type Error = Error; - type Output = RoleDeleted; - - async fn build_programmable_transaction(&self, client: &C) -> Result - where - C: CoreClientReadOnly + OptionalSync, - { - self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() - } - - async fn apply_with_events( - mut self, - _: &mut iota_interaction::rpc_types::IotaTransactionBlockEffects, - events: &mut iota_interaction::rpc_types::IotaTransactionBlockEvents, - _: &C, - ) -> Result - where - C: CoreClientReadOnly + OptionalSync, - { - for data in &events.data { - if let Ok(event) = serde_json::from_value::>(data.parsed_json.clone()) { - return Ok(event.data); - } - } - - Err(Error::UnexpectedApiResponse("RoleDeleted event not found".to_string())) - } - - async fn apply( - mut self, - _: &mut iota_interaction::rpc_types::IotaTransactionBlockEffects, - _: &C, - ) -> Result - where - C: CoreClientReadOnly + OptionalSync, - { - Err(Error::UnexpectedApiResponse( - "RoleDeleted output requires transaction events".to_string(), )) } -} -#[derive(Debug, Clone)] -pub struct IssueCapability { - trail_id: ObjectID, - owner: IotaAddress, - role: String, - options: CapabilityIssueOptions, - cached_ptb: OnceCell, -} - -impl IssueCapability { - pub fn new(trail_id: ObjectID, owner: IotaAddress, role: String, options: CapabilityIssueOptions) -> Self { - Self { - trail_id, - owner, - role, - options, - cached_ptb: OnceCell::new(), - } - } - - async fn make_ptb(&self, client: &C) -> Result + /// Deletes this role. + pub fn delete(&self) -> TransactionBuilder where - C: CoreClientReadOnly + OptionalSync, + C: AuditTrailFull + CoreClient, + S: Signer + OptionalSync, { - RolesOps::issue_capability( - client, + let owner = self.client.sender_address(); + TransactionBuilder::new(DeleteRole::new( self.trail_id, - self.owner, - self.role.clone(), - self.options.clone(), - ) - .await - } -} - -#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] -#[cfg_attr(feature = "send-sync", async_trait)] -impl Transaction for IssueCapability { - type Error = Error; - type Output = CapabilityIssued; - - async fn build_programmable_transaction(&self, client: &C) -> Result - where - C: CoreClientReadOnly + OptionalSync, - { - self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() - } - - async fn apply_with_events( - mut self, - _: &mut iota_interaction::rpc_types::IotaTransactionBlockEffects, - events: &mut iota_interaction::rpc_types::IotaTransactionBlockEvents, - _: &C, - ) -> Result - where - C: CoreClientReadOnly + OptionalSync, - { - for data in &events.data { - if let Ok(event) = serde_json::from_value::>(data.parsed_json.clone()) { - return Ok(event.data); - } - } - - Err(Error::UnexpectedApiResponse( - "CapabilityIssued event not found".to_string(), - )) - } - - async fn apply( - mut self, - _: &mut iota_interaction::rpc_types::IotaTransactionBlockEffects, - _: &C, - ) -> Result - where - C: CoreClientReadOnly + OptionalSync, - { - Err(Error::UnexpectedApiResponse( - "CapabilityIssued output requires transaction events".to_string(), - )) - } -} - -#[derive(Debug, Clone)] -pub struct RevokeCapability { - trail_id: ObjectID, - owner: IotaAddress, - capability_id: ObjectID, - cached_ptb: OnceCell, -} - -impl RevokeCapability { - pub fn new(trail_id: ObjectID, owner: IotaAddress, capability_id: ObjectID) -> Self { - Self { - trail_id, - owner, - capability_id, - cached_ptb: OnceCell::new(), - } - } - - async fn make_ptb(&self, client: &C) -> Result - where - C: CoreClientReadOnly + OptionalSync, - { - RolesOps::revoke_capability(client, self.trail_id, self.owner, self.capability_id).await - } -} - -#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] -#[cfg_attr(feature = "send-sync", async_trait)] -impl Transaction for RevokeCapability { - type Error = Error; - type Output = CapabilityRevoked; - - async fn build_programmable_transaction(&self, client: &C) -> Result - where - C: CoreClientReadOnly + OptionalSync, - { - self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() - } - - async fn apply_with_events( - mut self, - _: &mut iota_interaction::rpc_types::IotaTransactionBlockEffects, - events: &mut iota_interaction::rpc_types::IotaTransactionBlockEvents, - _: &C, - ) -> Result - where - C: CoreClientReadOnly + OptionalSync, - { - for data in &events.data { - if let Ok(event) = serde_json::from_value::>(data.parsed_json.clone()) { - return Ok(event.data); - } - } - - Err(Error::UnexpectedApiResponse( - "CapabilityRevoked event not found".to_string(), - )) - } - - async fn apply( - mut self, - _: &mut iota_interaction::rpc_types::IotaTransactionBlockEffects, - _: &C, - ) -> Result - where - C: CoreClientReadOnly + OptionalSync, - { - Err(Error::UnexpectedApiResponse( - "CapabilityRevoked output requires transaction events".to_string(), - )) - } -} - -#[derive(Debug, Clone)] -pub struct DestroyCapability { - trail_id: ObjectID, - owner: IotaAddress, - capability_id: ObjectID, - cached_ptb: OnceCell, -} - -impl DestroyCapability { - pub fn new(trail_id: ObjectID, owner: IotaAddress, capability_id: ObjectID) -> Self { - Self { - trail_id, owner, - capability_id, - cached_ptb: OnceCell::new(), - } - } - - async fn make_ptb(&self, client: &C) -> Result - where - C: CoreClientReadOnly + OptionalSync, - { - RolesOps::destroy_capability(client, self.trail_id, self.owner, self.capability_id).await - } -} - -#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] -#[cfg_attr(feature = "send-sync", async_trait)] -impl Transaction for DestroyCapability { - type Error = Error; - type Output = CapabilityDestroyed; - - async fn build_programmable_transaction(&self, client: &C) -> Result - where - C: CoreClientReadOnly + OptionalSync, - { - self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() - } - - async fn apply_with_events( - mut self, - _: &mut iota_interaction::rpc_types::IotaTransactionBlockEffects, - events: &mut iota_interaction::rpc_types::IotaTransactionBlockEvents, - _: &C, - ) -> Result - where - C: CoreClientReadOnly + OptionalSync, - { - for data in &events.data { - if let Ok(event) = serde_json::from_value::>(data.parsed_json.clone()) { - return Ok(event.data); - } - } - - Err(Error::UnexpectedApiResponse( - "CapabilityDestroyed event not found".to_string(), - )) - } - - async fn apply( - mut self, - _: &mut iota_interaction::rpc_types::IotaTransactionBlockEffects, - _: &C, - ) -> Result - where - C: CoreClientReadOnly + OptionalSync, - { - Err(Error::UnexpectedApiResponse( - "CapabilityDestroyed output requires transaction events".to_string(), + self.name.clone(), )) } } diff --git a/audit-trail-rs/src/core/roles/operations.rs b/audit-trail-rs/src/core/roles/operations.rs index 8a3bfdff..770b5ea9 100644 --- a/audit-trail-rs/src/core/roles/operations.rs +++ b/audit-trail-rs/src/core/roles/operations.rs @@ -197,6 +197,50 @@ impl RolesOps { .await } + pub(super) async fn destroy_initial_admin_capability( + client: &C, + trail_id: ObjectID, + capability_id: ObjectID, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + let cap_ref = utils::get_object_ref_by_id(client, &capability_id).await?; + operations::build_trail_transaction_with_cap_ref( + client, + trail_id, + cap_ref, + "destroy_initial_admin_capability", + |_, _| Ok(vec![]), + ) + .await + } + + pub(super) async fn revoke_initial_admin_capability( + client: &C, + trail_id: ObjectID, + owner: IotaAddress, + capability_id: ObjectID, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + let admin_cap_ref = Self::get_admin_capability_ref(client, owner, trail_id).await?; + operations::build_trail_transaction_with_cap_ref( + client, + trail_id, + admin_cap_ref, + "revoke_initial_admin_capability", + |ptb, _| { + let cap = utils::ptb_pure(ptb, "capability_id", capability_id)?; + let clock = utils::get_clock_ref(ptb); + + Ok(vec![cap, clock]) + }, + ) + .await + } + async fn get_admin_capability_ref( client: &C, owner: IotaAddress, diff --git a/audit-trail-rs/src/core/roles/transactions.rs b/audit-trail-rs/src/core/roles/transactions.rs new file mode 100644 index 00000000..6ee930af --- /dev/null +++ b/audit-trail-rs/src/core/roles/transactions.rs @@ -0,0 +1,630 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use async_trait::async_trait; +use iota_interaction::rpc_types::{IotaTransactionBlockEffects, IotaTransactionBlockEvents}; +use iota_interaction::types::base_types::{IotaAddress, ObjectID}; +use iota_interaction::types::transaction::ProgrammableTransaction; +use iota_interaction::OptionalSync; +use product_common::core_client::CoreClientReadOnly; +use product_common::transaction::transaction_builder::Transaction; +use tokio::sync::OnceCell; + +use crate::core::types::{ + CapabilityDestroyed, CapabilityIssueOptions, CapabilityIssued, CapabilityRevoked, Event, PermissionSet, + RoleCreated, RoleDeleted, RoleUpdated, +}; +use crate::error::Error; + +use super::operations::RolesOps; + +// ===== CreateRole ===== + +#[derive(Debug, Clone)] +pub struct CreateRole { + trail_id: ObjectID, + owner: IotaAddress, + name: String, + permissions: PermissionSet, + cached_ptb: OnceCell, +} + +impl CreateRole { + pub fn new(trail_id: ObjectID, owner: IotaAddress, name: String, permissions: PermissionSet) -> Self { + Self { + trail_id, + owner, + name, + permissions, + cached_ptb: OnceCell::new(), + } + } + + async fn make_ptb(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + RolesOps::create_role( + client, + self.trail_id, + self.owner, + self.name.clone(), + self.permissions.clone(), + ) + .await + } +} + +#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] +#[cfg_attr(feature = "send-sync", async_trait)] +impl Transaction for CreateRole { + type Error = Error; + type Output = RoleCreated; + + async fn build_programmable_transaction(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() + } + + async fn apply_with_events( + mut self, + _: &mut IotaTransactionBlockEffects, + events: &mut IotaTransactionBlockEvents, + _: &C, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + for data in &events.data { + if let Ok(event) = serde_json::from_value::>(data.parsed_json.clone()) { + return Ok(event.data); + } + } + + Err(Error::UnexpectedApiResponse("RoleCreated event not found".to_string())) + } + + async fn apply(mut self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + Err(Error::UnexpectedApiResponse( + "RoleCreated output requires transaction events".to_string(), + )) + } +} + +// ===== UpdateRole ===== + +#[derive(Debug, Clone)] +pub struct UpdateRole { + trail_id: ObjectID, + owner: IotaAddress, + name: String, + permissions: PermissionSet, + cached_ptb: OnceCell, +} + +impl UpdateRole { + pub fn new(trail_id: ObjectID, owner: IotaAddress, name: String, permissions: PermissionSet) -> Self { + Self { + trail_id, + owner, + name, + permissions, + cached_ptb: OnceCell::new(), + } + } + + async fn make_ptb(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + RolesOps::update_role( + client, + self.trail_id, + self.owner, + self.name.clone(), + self.permissions.clone(), + ) + .await + } +} + +#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] +#[cfg_attr(feature = "send-sync", async_trait)] +impl Transaction for UpdateRole { + type Error = Error; + type Output = RoleUpdated; + + async fn build_programmable_transaction(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() + } + + async fn apply_with_events( + mut self, + _: &mut IotaTransactionBlockEffects, + events: &mut IotaTransactionBlockEvents, + _: &C, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + for data in &events.data { + if let Ok(event) = serde_json::from_value::>(data.parsed_json.clone()) { + return Ok(event.data); + } + } + + Err(Error::UnexpectedApiResponse("RoleUpdated event not found".to_string())) + } + + async fn apply(mut self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + Err(Error::UnexpectedApiResponse( + "RoleUpdated output requires transaction events".to_string(), + )) + } +} + +// ===== DeleteRole ===== + +#[derive(Debug, Clone)] +pub struct DeleteRole { + trail_id: ObjectID, + owner: IotaAddress, + name: String, + cached_ptb: OnceCell, +} + +impl DeleteRole { + pub fn new(trail_id: ObjectID, owner: IotaAddress, name: String) -> Self { + Self { + trail_id, + owner, + name, + cached_ptb: OnceCell::new(), + } + } + + async fn make_ptb(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + RolesOps::delete_role(client, self.trail_id, self.owner, self.name.clone()).await + } +} + +#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] +#[cfg_attr(feature = "send-sync", async_trait)] +impl Transaction for DeleteRole { + type Error = Error; + type Output = RoleDeleted; + + async fn build_programmable_transaction(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() + } + + async fn apply_with_events( + mut self, + _: &mut IotaTransactionBlockEffects, + events: &mut IotaTransactionBlockEvents, + _: &C, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + for data in &events.data { + if let Ok(event) = serde_json::from_value::>(data.parsed_json.clone()) { + return Ok(event.data); + } + } + + Err(Error::UnexpectedApiResponse("RoleDeleted event not found".to_string())) + } + + async fn apply( + mut self, + _: &mut IotaTransactionBlockEffects, + _: &C, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + Err(Error::UnexpectedApiResponse( + "RoleDeleted output requires transaction events".to_string(), + )) + } +} + +// ===== IssueCapability ===== + +#[derive(Debug, Clone)] +pub struct IssueCapability { + trail_id: ObjectID, + owner: IotaAddress, + role: String, + options: CapabilityIssueOptions, + cached_ptb: OnceCell, +} + +impl IssueCapability { + pub fn new(trail_id: ObjectID, owner: IotaAddress, role: String, options: CapabilityIssueOptions) -> Self { + Self { + trail_id, + owner, + role, + options, + cached_ptb: OnceCell::new(), + } + } + + async fn make_ptb(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + RolesOps::issue_capability( + client, + self.trail_id, + self.owner, + self.role.clone(), + self.options.clone(), + ) + .await + } +} + +#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] +#[cfg_attr(feature = "send-sync", async_trait)] +impl Transaction for IssueCapability { + type Error = Error; + type Output = CapabilityIssued; + + async fn build_programmable_transaction(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() + } + + async fn apply_with_events( + mut self, + _: &mut IotaTransactionBlockEffects, + events: &mut IotaTransactionBlockEvents, + _: &C, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + for data in &events.data { + if let Ok(event) = serde_json::from_value::>(data.parsed_json.clone()) { + return Ok(event.data); + } + } + + Err(Error::UnexpectedApiResponse( + "CapabilityIssued event not found".to_string(), + )) + } + + async fn apply( + mut self, + _: &mut IotaTransactionBlockEffects, + _: &C, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + Err(Error::UnexpectedApiResponse( + "CapabilityIssued output requires transaction events".to_string(), + )) + } +} + +// ===== RevokeCapability ===== + +#[derive(Debug, Clone)] +pub struct RevokeCapability { + trail_id: ObjectID, + owner: IotaAddress, + capability_id: ObjectID, + cached_ptb: OnceCell, +} + +impl RevokeCapability { + pub fn new(trail_id: ObjectID, owner: IotaAddress, capability_id: ObjectID) -> Self { + Self { + trail_id, + owner, + capability_id, + cached_ptb: OnceCell::new(), + } + } + + async fn make_ptb(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + RolesOps::revoke_capability(client, self.trail_id, self.owner, self.capability_id).await + } +} + +#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] +#[cfg_attr(feature = "send-sync", async_trait)] +impl Transaction for RevokeCapability { + type Error = Error; + type Output = CapabilityRevoked; + + async fn build_programmable_transaction(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() + } + + async fn apply_with_events( + mut self, + _: &mut IotaTransactionBlockEffects, + events: &mut IotaTransactionBlockEvents, + _: &C, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + for data in &events.data { + if let Ok(event) = serde_json::from_value::>(data.parsed_json.clone()) { + return Ok(event.data); + } + } + + Err(Error::UnexpectedApiResponse( + "CapabilityRevoked event not found".to_string(), + )) + } + + async fn apply( + mut self, + _: &mut IotaTransactionBlockEffects, + _: &C, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + Err(Error::UnexpectedApiResponse( + "CapabilityRevoked output requires transaction events".to_string(), + )) + } +} + +// ===== DestroyCapability ===== + +#[derive(Debug, Clone)] +pub struct DestroyCapability { + trail_id: ObjectID, + owner: IotaAddress, + capability_id: ObjectID, + cached_ptb: OnceCell, +} + +impl DestroyCapability { + pub fn new(trail_id: ObjectID, owner: IotaAddress, capability_id: ObjectID) -> Self { + Self { + trail_id, + owner, + capability_id, + cached_ptb: OnceCell::new(), + } + } + + async fn make_ptb(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + RolesOps::destroy_capability(client, self.trail_id, self.owner, self.capability_id).await + } +} + +#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] +#[cfg_attr(feature = "send-sync", async_trait)] +impl Transaction for DestroyCapability { + type Error = Error; + type Output = CapabilityDestroyed; + + async fn build_programmable_transaction(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() + } + + async fn apply_with_events( + mut self, + _: &mut IotaTransactionBlockEffects, + events: &mut IotaTransactionBlockEvents, + _: &C, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + for data in &events.data { + if let Ok(event) = serde_json::from_value::>(data.parsed_json.clone()) { + return Ok(event.data); + } + } + + Err(Error::UnexpectedApiResponse( + "CapabilityDestroyed event not found".to_string(), + )) + } + + async fn apply( + mut self, + _: &mut IotaTransactionBlockEffects, + _: &C, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + Err(Error::UnexpectedApiResponse( + "CapabilityDestroyed output requires transaction events".to_string(), + )) + } +} + +// ===== DestroyInitialAdminCapability ===== + +#[derive(Debug, Clone)] +pub struct DestroyInitialAdminCapability { + trail_id: ObjectID, + capability_id: ObjectID, + cached_ptb: OnceCell, +} + +impl DestroyInitialAdminCapability { + pub fn new(trail_id: ObjectID, capability_id: ObjectID) -> Self { + Self { + trail_id, + capability_id, + cached_ptb: OnceCell::new(), + } + } + + async fn make_ptb(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + RolesOps::destroy_initial_admin_capability(client, self.trail_id, self.capability_id).await + } +} + +#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] +#[cfg_attr(feature = "send-sync", async_trait)] +impl Transaction for DestroyInitialAdminCapability { + type Error = Error; + type Output = CapabilityDestroyed; + + async fn build_programmable_transaction(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() + } + + async fn apply_with_events( + mut self, + _: &mut IotaTransactionBlockEffects, + events: &mut IotaTransactionBlockEvents, + _: &C, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + for data in &events.data { + if let Ok(event) = serde_json::from_value::>(data.parsed_json.clone()) { + return Ok(event.data); + } + } + + Err(Error::UnexpectedApiResponse( + "CapabilityDestroyed event not found".to_string(), + )) + } + + async fn apply( + mut self, + _: &mut IotaTransactionBlockEffects, + _: &C, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + Err(Error::UnexpectedApiResponse( + "CapabilityDestroyed output requires transaction events".to_string(), + )) + } +} + +// ===== RevokeInitialAdminCapability ===== + +#[derive(Debug, Clone)] +pub struct RevokeInitialAdminCapability { + trail_id: ObjectID, + owner: IotaAddress, + capability_id: ObjectID, + cached_ptb: OnceCell, +} + +impl RevokeInitialAdminCapability { + pub fn new(trail_id: ObjectID, owner: IotaAddress, capability_id: ObjectID) -> Self { + Self { + trail_id, + owner, + capability_id, + cached_ptb: OnceCell::new(), + } + } + + async fn make_ptb(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + RolesOps::revoke_initial_admin_capability(client, self.trail_id, self.owner, self.capability_id).await + } +} + +#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] +#[cfg_attr(feature = "send-sync", async_trait)] +impl Transaction for RevokeInitialAdminCapability { + type Error = Error; + type Output = CapabilityRevoked; + + async fn build_programmable_transaction(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() + } + + async fn apply_with_events( + mut self, + _: &mut IotaTransactionBlockEffects, + events: &mut IotaTransactionBlockEvents, + _: &C, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + for data in &events.data { + if let Ok(event) = serde_json::from_value::>(data.parsed_json.clone()) { + return Ok(event.data); + } + } + + Err(Error::UnexpectedApiResponse( + "CapabilityRevoked event not found".to_string(), + )) + } + + async fn apply( + mut self, + _: &mut IotaTransactionBlockEffects, + _: &C, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + Err(Error::UnexpectedApiResponse( + "CapabilityRevoked output requires transaction events".to_string(), + )) + } +} diff --git a/audit-trail-rs/src/core/trail.rs b/audit-trail-rs/src/core/trail.rs index 25acc613..3cc66c82 100644 --- a/audit-trail-rs/src/core/trail.rs +++ b/audit-trail-rs/src/core/trail.rs @@ -42,10 +42,6 @@ impl<'a, C> AuditTrailHandle<'a, C> { TrailRecords::new(self.client, self.trail_id) } - pub fn records_as(&self) -> TrailRecords<'a, C, D> { - TrailRecords::new(self.client, self.trail_id) - } - pub fn locking(&self) -> TrailLocking<'a, C> { TrailLocking::new(self.client, self.trail_id) } @@ -57,5 +53,4 @@ impl<'a, C> AuditTrailHandle<'a, C> { pub fn roles(&self) -> TrailRoles<'a, C> { TrailRoles::new(self.client, self.trail_id) } - } diff --git a/audit-trail-rs/src/core/types/role_map.rs b/audit-trail-rs/src/core/types/role_map.rs index 1ad26fc4..b089e7b1 100644 --- a/audit-trail-rs/src/core/types/role_map.rs +++ b/audit-trail-rs/src/core/types/role_map.rs @@ -16,6 +16,17 @@ use crate::core::utils::deserialize_vec_set; use super::permission::Permission; +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RoleMap { + pub target_key: ObjectID, + #[serde(deserialize_with = "deserialize_vec_map")] + pub roles: HashMap>, + #[serde(deserialize_with = "deserialize_vec_set")] + pub issued_capabilities: HashSet, + pub role_admin_permissions: RoleAdminPermissions, + pub capability_admin_permissions: CapabilityAdminPermissions, +} + /// Defines the permissions required to administer roles in this RoleMap. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RoleAdminPermissions { @@ -55,18 +66,3 @@ impl MoveType for Capability { TypeTag::from_str(format!("{package}::capability::Capability").as_str()).expect("failed to create type tag") } } - -/// A simplified Rust representation of the on-chain RoleMap. -/// -/// Note: The Move type uses VecMap/VecSet; this struct represents those -/// collections as Rust vectors for convenience. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct RoleMap { - pub target_key: ObjectID, - #[serde(deserialize_with = "deserialize_vec_map")] - pub roles: HashMap>, - #[serde(deserialize_with = "deserialize_vec_set")] - pub issued_capabilities: HashSet, - pub role_admin_permissions: RoleAdminPermissions, - pub capability_admin_permissions: CapabilityAdminPermissions, -} diff --git a/audit-trail-rs/tests/e2e/audit_trail_creations.rs b/audit-trail-rs/tests/e2e/audit_trail_creations.rs index 0ed22f01..80bf3842 100644 --- a/audit-trail-rs/tests/e2e/audit_trail_creations.rs +++ b/audit-trail-rs/tests/e2e/audit_trail_creations.rs @@ -13,7 +13,7 @@ async fn create_trail_with_default_builder_settings() -> anyhow::Result<()> { let created = client .create_trail() .with_initial_record(Data::text("audit-trail-create-default"), None) - .finish()? + .finish() .build_and_execute(&client) .await? .output; @@ -47,7 +47,7 @@ async fn create_trail_with_metadata_and_time_lock() -> anyhow::Result<()> { .with_locking_config(LockingConfig::time_based(300)) .with_trail_metadata(immutable_metadata.clone()) .with_updatable_metadata("updatable metadata") - .finish()? + .finish() .build_and_execute(&client) .await? .output; @@ -72,7 +72,7 @@ async fn create_trail_with_bytes_and_count_lock() -> anyhow::Result<()> { ) .with_locking_config(LockingConfig::count_based(3)) .with_trail_metadata_parts("Trail Count Lock", Some("count lock description".to_string())) - .finish()? + .finish() .build_and_execute(&client) .await? .output; @@ -93,7 +93,7 @@ async fn create_trail_with_custom_admin_address() -> anyhow::Result<()> { .create_trail() .with_admin(custom_admin) .with_initial_record(Data::text("audit-trail-custom-admin"), None) - .finish()? + .finish() .build_and_execute(&client) .await? .output; diff --git a/audit-trail-rs/tests/e2e/records.rs b/audit-trail-rs/tests/e2e/records.rs index e22fd731..39e7ddec 100644 --- a/audit-trail-rs/tests/e2e/records.rs +++ b/audit-trail-rs/tests/e2e/records.rs @@ -12,7 +12,7 @@ async fn add_and_fetch_record_roundtrip() -> anyhow::Result<()> { let created = client .create_trail() .with_initial_record(Data::text("audit-trail-e2e"), metadata.clone()) - .finish()? + .finish() .build_and_execute(&client) .await? .output; diff --git a/audit-trail-rs/tests/e2e/roles.rs b/audit-trail-rs/tests/e2e/roles.rs index b8058028..4f8eb951 100644 --- a/audit-trail-rs/tests/e2e/roles.rs +++ b/audit-trail-rs/tests/e2e/roles.rs @@ -4,13 +4,14 @@ use crate::client::get_funded_test_client; use audit_trails::core::types::{CapabilityIssueOptions, Data, Permission, PermissionSet, RoleCreated}; use iota_interaction::types::base_types::{IotaAddress, ObjectID}; +use product_common::core_client::CoreClient; use std::collections::HashSet; async fn create_trail(client: &crate::client::TestClient) -> anyhow::Result { let created = client .create_trail() .with_initial_record(Data::text("roles-e2e"), None) - .finish()? + .finish() .build_and_execute(client) .await? .output; @@ -27,10 +28,8 @@ async fn create_role_with_permissions( let created = client .trail(trail_id) .roles() - .create( - role_name.to_string(), - PermissionSet { permissions }, - )? + .for_role(role_name) + .create(PermissionSet { permissions }) .build_and_execute(client) .await? .output; @@ -54,7 +53,7 @@ async fn create_role_then_issue_capability_default_options() -> anyhow::Result<( let issued = roles .for_role(role_name) - .issue_capability(CapabilityIssueOptions::default())? + .issue_capability(CapabilityIssueOptions::default()) .build_and_execute(&client) .await? .output; @@ -78,12 +77,10 @@ async fn update_role_permissions_then_issue_capability() -> anyhow::Result<()> { create_role_with_permissions(&client, trail_id, role_name, vec![Permission::AddRecord]).await?; let updated = roles - .update( - role_name.to_string(), - PermissionSet { - permissions: vec![Permission::AddRecord, Permission::DeleteRecord], - }, - )? + .for_role(role_name) + .update_permissions(PermissionSet { + permissions: vec![Permission::AddRecord, Permission::DeleteRecord], + }) .build_and_execute(&client) .await? .output; @@ -99,7 +96,7 @@ async fn update_role_permissions_then_issue_capability() -> anyhow::Result<()> { let issued = roles .for_role(role_name) - .issue_capability(CapabilityIssueOptions::default())? + .issue_capability(CapabilityIssueOptions::default()) .build_and_execute(&client) .await? .output; @@ -118,7 +115,8 @@ async fn delete_role_prevents_new_capability_issuance() -> anyhow::Result<()> { create_role_with_permissions(&client, trail_id, role_name, vec![Permission::AddRecord]).await?; let deleted = roles - .delete(role_name.to_string())? + .for_role(role_name) + .delete() .build_and_execute(&client) .await? .output; @@ -128,7 +126,7 @@ async fn delete_role_prevents_new_capability_issuance() -> anyhow::Result<()> { let issue_tx = roles .for_role(role_name) - .issue_capability(CapabilityIssueOptions::default())?; + .issue_capability(CapabilityIssueOptions::default()); let issue_after_delete = issue_tx.build_and_execute(&client).await; assert!( issue_after_delete.is_err(), @@ -155,7 +153,7 @@ async fn issue_capability_with_constraints() -> anyhow::Result<()> { let issued = roles .for_role(role_name) - .issue_capability(constrained.clone())? + .issue_capability(constrained.clone()) .build_and_execute(&client) .await? .output; @@ -180,13 +178,13 @@ async fn revoke_capability_emits_expected_event_data() -> anyhow::Result<()> { let issued = roles .for_role(role_name) - .issue_capability(CapabilityIssueOptions::default())? + .issue_capability(CapabilityIssueOptions::default()) .build_and_execute(&client) .await? .output; let revoked = roles - .revoke_capability(issued.capability_id)? + .revoke_capability(issued.capability_id) .build_and_execute(&client) .await? .output; @@ -207,13 +205,13 @@ async fn destroy_capability_emits_expected_event_data() -> anyhow::Result<()> { let issued_for_destroy = roles .for_role(role_name) - .issue_capability(CapabilityIssueOptions::default())? + .issue_capability(CapabilityIssueOptions::default()) .build_and_execute(&client) .await? .output; let destroyed = roles - .destroy_capability(issued_for_destroy.capability_id)? + .destroy_capability(issued_for_destroy.capability_id) .build_and_execute(&client) .await? .output; @@ -227,3 +225,98 @@ async fn destroy_capability_emits_expected_event_data() -> anyhow::Result<()> { Ok(()) } + +#[tokio::test] +async fn destroy_initial_admin_capability_emits_expected_event() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = create_trail(&client).await?; + let roles = client.trail(trail_id).roles(); + + let admin_cap_ref = client.get_cap(client.sender_address(), trail_id).await?; + let admin_cap_id = ObjectID::from(admin_cap_ref.0); + + let destroyed = roles + .destroy_initial_admin_capability(admin_cap_id) + .build_and_execute(&client) + .await? + .output; + + assert_eq!(destroyed.target_key, trail_id); + assert_eq!(destroyed.capability_id, admin_cap_id); + assert_eq!(destroyed.role, "Admin".to_string()); + assert_eq!(destroyed.issued_to, None); + assert_eq!(destroyed.valid_from, None); + assert_eq!(destroyed.valid_until, None); + + Ok(()) +} + +#[tokio::test] +async fn revoke_initial_admin_capability_emits_expected_event() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = create_trail(&client).await?; + let roles = client.trail(trail_id).roles(); + + // Issue a second admin capability so we can use the original to revoke it + let second_admin = roles + .for_role("Admin") + .issue_capability(CapabilityIssueOptions::default()) + .build_and_execute(&client) + .await? + .output; + + let revoked = roles + .revoke_initial_admin_capability(second_admin.capability_id) + .build_and_execute(&client) + .await? + .output; + + assert_eq!(revoked.target_key, trail_id); + assert_eq!(revoked.capability_id, second_admin.capability_id); + + Ok(()) +} + +#[tokio::test] +async fn regular_destroy_rejects_initial_admin_capability() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = create_trail(&client).await?; + let roles = client.trail(trail_id).roles(); + + let admin_cap_ref = client.get_cap(client.sender_address(), trail_id).await?; + let admin_cap_id = ObjectID::from(admin_cap_ref.0); + + let result = roles + .destroy_capability(admin_cap_id) + .build_and_execute(&client) + .await; + + assert!( + result.is_err(), + "destroying an initial admin cap via regular destroy_capability must fail" + ); + + Ok(()) +} + +#[tokio::test] +async fn regular_revoke_rejects_initial_admin_capability() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = create_trail(&client).await?; + let roles = client.trail(trail_id).roles(); + + let admin_cap_ref = client.get_cap(client.sender_address(), trail_id).await?; + let admin_cap_id = ObjectID::from(admin_cap_ref.0); + + let result = roles + .revoke_capability(admin_cap_id) + .build_and_execute(&client) + .await; + + assert!( + result.is_err(), + "revoking an initial admin cap via regular revoke_capability must fail" + ); + + Ok(()) +} diff --git a/notarization-move/Move.history.json b/notarization-move/Move.history.json index e3d523ac..9da3cf65 100644 --- a/notarization-move/Move.history.json +++ b/notarization-move/Move.history.json @@ -1,8 +1,8 @@ { "aliases": { "testnet": "2304aa97", - "devnet": "e678123a", - "mainnet": "6364aad5" + "mainnet": "6364aad5", + "devnet": "e678123a" }, "envs": { "6364aad5": [ From 83307525a25ef0294bd363cfe823d8afbe78021d Mon Sep 17 00:00:00 2001 From: Yasir Date: Mon, 16 Feb 2026 16:29:20 +0300 Subject: [PATCH 058/189] refactor: remove metadata module and related tests; introduce operations and transactions modules for better structure --- audit-trail-rs/src/core/metadata/mod.rs | 26 -- audit-trail-rs/src/core/mod.rs | 1 - audit-trail-rs/src/core/trail.rs | 45 ++- audit-trail-rs/src/core/trail/operations.rs | 32 ++ audit-trail-rs/src/core/trail/transactions.rs | 76 +++++ .../tests/e2e/audit_trail_creations.rs | 111 ------- audit-trail-rs/tests/e2e/main.rs | 2 +- audit-trail-rs/tests/e2e/trail.rs | 305 ++++++++++++++++++ 8 files changed, 451 insertions(+), 147 deletions(-) delete mode 100644 audit-trail-rs/src/core/metadata/mod.rs create mode 100644 audit-trail-rs/src/core/trail/operations.rs create mode 100644 audit-trail-rs/src/core/trail/transactions.rs delete mode 100644 audit-trail-rs/tests/e2e/audit_trail_creations.rs create mode 100644 audit-trail-rs/tests/e2e/trail.rs diff --git a/audit-trail-rs/src/core/metadata/mod.rs b/audit-trail-rs/src/core/metadata/mod.rs deleted file mode 100644 index f2e81b9b..00000000 --- a/audit-trail-rs/src/core/metadata/mod.rs +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright 2020-2026 IOTA Stiftung -// SPDX-License-Identifier: Apache-2.0 - -use iota_interaction::types::base_types::ObjectID; - -use crate::core::trail::AuditTrailFull; -use crate::error::Error; - -#[derive(Debug, Clone)] -pub struct TrailMetadata<'a, C> { - pub(crate) client: &'a C, - pub(crate) trail_id: ObjectID, -} - -impl<'a, C> TrailMetadata<'a, C> { - pub(crate) fn new(client: &'a C, trail_id: ObjectID) -> Self { - Self { client, trail_id } - } - - pub async fn update(&self, _metadata: Option) -> Result<(), Error> - where - C: AuditTrailFull, - { - Err(Error::NotImplemented("TrailMetadata::update")) - } -} diff --git a/audit-trail-rs/src/core/mod.rs b/audit-trail-rs/src/core/mod.rs index 75146665..8794d361 100644 --- a/audit-trail-rs/src/core/mod.rs +++ b/audit-trail-rs/src/core/mod.rs @@ -6,7 +6,6 @@ pub mod builder; pub mod create; pub mod locking; -pub mod metadata; pub(crate) mod operations; pub(crate) mod utils; pub mod records; diff --git a/audit-trail-rs/src/core/trail.rs b/audit-trail-rs/src/core/trail.rs index 3cc66c82..3114633d 100644 --- a/audit-trail-rs/src/core/trail.rs +++ b/audit-trail-rs/src/core/trail.rs @@ -1,19 +1,25 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use iota_interaction::OptionalSync; +use iota_interaction::{IotaKeySignature, OptionalSync}; use iota_interaction::types::base_types::ObjectID; use iota_interaction::types::transaction::ProgrammableTransaction; -use product_common::core_client::CoreClientReadOnly; +use product_common::core_client::{CoreClient, CoreClientReadOnly}; +use product_common::transaction::transaction_builder::TransactionBuilder; +use secret_storage::Signer; use serde::de::DeserializeOwned; use crate::core::locking::TrailLocking; -use crate::core::metadata::TrailMetadata; use crate::core::records::TrailRecords; use crate::core::roles::TrailRoles; -use crate::core::types::Data; +use crate::core::types::{Data, OnChainAuditTrail}; use crate::error::Error; +mod operations; +mod transactions; + +pub use transactions::UpdateMetadata; + /// Marker trait for read-only audit trail clients. #[doc(hidden)] #[async_trait::async_trait] @@ -38,6 +44,33 @@ impl<'a, C> AuditTrailHandle<'a, C> { Self { client, trail_id } } + /// Loads the full on-chain audit trail object. + pub async fn get(&self) -> Result + where + C: AuditTrailReadOnly, + { + self.client.get_object_by_id(self.trail_id).await.map_err(|err| { + Error::UnexpectedApiResponse(format!( + "failed to load on-chain trail {}; {err}", + self.trail_id + )) + }) + } + + /// Updates the trail's updatable metadata. + pub fn update_metadata(&self, metadata: Option) -> TransactionBuilder + where + C: AuditTrailFull + CoreClient, + S: Signer + OptionalSync, + { + let owner = self.client.sender_address(); + TransactionBuilder::new(UpdateMetadata::new( + self.trail_id, + owner, + metadata, + )) + } + pub fn records(&self) -> TrailRecords<'a, C, Data> { TrailRecords::new(self.client, self.trail_id) } @@ -46,10 +79,6 @@ impl<'a, C> AuditTrailHandle<'a, C> { TrailLocking::new(self.client, self.trail_id) } - pub fn metadata(&self) -> TrailMetadata<'a, C> { - TrailMetadata::new(self.client, self.trail_id) - } - pub fn roles(&self) -> TrailRoles<'a, C> { TrailRoles::new(self.client, self.trail_id) } diff --git a/audit-trail-rs/src/core/trail/operations.rs b/audit-trail-rs/src/core/trail/operations.rs new file mode 100644 index 00000000..3b720f91 --- /dev/null +++ b/audit-trail-rs/src/core/trail/operations.rs @@ -0,0 +1,32 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use iota_interaction::types::base_types::{IotaAddress, ObjectID}; +use iota_interaction::types::transaction::ProgrammableTransaction; +use iota_interaction::OptionalSync; +use product_common::core_client::CoreClientReadOnly; + +use crate::core::operations; +use crate::core::utils; +use crate::error::Error; + +pub(super) struct TrailOps; + +impl TrailOps { + pub(super) async fn update_metadata( + client: &C, + trail_id: ObjectID, + owner: IotaAddress, + metadata: Option, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + operations::build_trail_transaction_for_owner(client, trail_id, owner, "update_metadata", |ptb, _| { + let metadata_arg = utils::ptb_pure(ptb, "new_metadata", metadata)?; + let clock = utils::get_clock_ref(ptb); + Ok(vec![metadata_arg, clock]) + }) + .await + } +} diff --git a/audit-trail-rs/src/core/trail/transactions.rs b/audit-trail-rs/src/core/trail/transactions.rs new file mode 100644 index 00000000..06a8a266 --- /dev/null +++ b/audit-trail-rs/src/core/trail/transactions.rs @@ -0,0 +1,76 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use async_trait::async_trait; +use iota_interaction::rpc_types::{IotaTransactionBlockEffects, IotaTransactionBlockEvents}; +use iota_interaction::types::base_types::{IotaAddress, ObjectID}; +use iota_interaction::types::transaction::ProgrammableTransaction; +use iota_interaction::OptionalSync; +use product_common::core_client::CoreClientReadOnly; +use product_common::transaction::transaction_builder::Transaction; +use tokio::sync::OnceCell; + +use crate::error::Error; + +use super::operations::TrailOps; + +// ===== UpdateMetadata ===== + +#[derive(Debug, Clone)] +pub struct UpdateMetadata { + trail_id: ObjectID, + owner: IotaAddress, + metadata: Option, + cached_ptb: OnceCell, +} + +impl UpdateMetadata { + pub fn new(trail_id: ObjectID, owner: IotaAddress, metadata: Option) -> Self { + Self { + trail_id, + owner, + metadata, + cached_ptb: OnceCell::new(), + } + } + + async fn make_ptb(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + TrailOps::update_metadata(client, self.trail_id, self.owner, self.metadata.clone()).await + } +} + +#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] +#[cfg_attr(feature = "send-sync", async_trait)] +impl Transaction for UpdateMetadata { + type Error = Error; + type Output = (); + + async fn build_programmable_transaction(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() + } + + async fn apply(self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + Ok(()) + } + + async fn apply_with_events( + self, + effects: &mut IotaTransactionBlockEffects, + _events: &mut IotaTransactionBlockEvents, + client: &C, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + self.apply(effects, client).await + } +} diff --git a/audit-trail-rs/tests/e2e/audit_trail_creations.rs b/audit-trail-rs/tests/e2e/audit_trail_creations.rs deleted file mode 100644 index 80bf3842..00000000 --- a/audit-trail-rs/tests/e2e/audit_trail_creations.rs +++ /dev/null @@ -1,111 +0,0 @@ -// Copyright 2020-2026 IOTA Stiftung -// SPDX-License-Identifier: Apache-2.0 - -use crate::client::get_funded_test_client; -use audit_trails::core::types::{Data, ImmutableMetadata, LockingConfig}; -use iota_interaction::types::base_types::IotaAddress; -use product_common::core_client::CoreClient; - -#[tokio::test] -async fn create_trail_with_default_builder_settings() -> anyhow::Result<()> { - let client = get_funded_test_client().await?; - - let created = client - .create_trail() - .with_initial_record(Data::text("audit-trail-create-default"), None) - .finish() - .build_and_execute(&client) - .await? - .output; - - assert_eq!(created.creator, client.sender_address()); - - let on_chain = created.load_on_chain(&client).await?; - assert_eq!(on_chain.id.object_id(), &created.trail_id); - assert_eq!(on_chain.creator, client.sender_address()); - assert_eq!(on_chain.sequence_number, 1); - assert_eq!(on_chain.locking_config, LockingConfig::none()); - assert!(on_chain.immutable_metadata.is_none()); - assert!(on_chain.updatable_metadata.is_none()); - - Ok(()) -} - -#[tokio::test] -async fn create_trail_with_metadata_and_time_lock() -> anyhow::Result<()> { - let client = get_funded_test_client().await?; - - let immutable_metadata = - ImmutableMetadata::new("Trail Time Lock".to_string(), Some("immutable description".to_string())); - - let created = client - .create_trail() - .with_initial_record( - Data::text("audit-trail-create-time-lock"), - Some("initial record metadata".to_string()), - ) - .with_locking_config(LockingConfig::time_based(300)) - .with_trail_metadata(immutable_metadata.clone()) - .with_updatable_metadata("updatable metadata") - .finish() - .build_and_execute(&client) - .await? - .output; - - let on_chain = created.load_on_chain(&client).await?; - assert_eq!(on_chain.locking_config, LockingConfig::time_based(300)); - assert_eq!(on_chain.immutable_metadata, Some(immutable_metadata)); - assert_eq!(on_chain.updatable_metadata, Some("updatable metadata".to_string())); - - Ok(()) -} - -#[tokio::test] -async fn create_trail_with_bytes_and_count_lock() -> anyhow::Result<()> { - let client = get_funded_test_client().await?; - - let created = client - .create_trail() - .with_initial_record( - Data::bytes(vec![0xAA, 0xBB, 0xCC, 0xDD]), - Some("bytes metadata".to_string()), - ) - .with_locking_config(LockingConfig::count_based(3)) - .with_trail_metadata_parts("Trail Count Lock", Some("count lock description".to_string())) - .finish() - .build_and_execute(&client) - .await? - .output; - - let on_chain = created.load_on_chain(&client).await?; - assert_eq!(on_chain.locking_config, LockingConfig::count_based(3)); - assert_eq!(on_chain.sequence_number, 1); - - Ok(()) -} - -#[tokio::test] -async fn create_trail_with_custom_admin_address() -> anyhow::Result<()> { - let client = get_funded_test_client().await?; - let custom_admin = IotaAddress::random_for_testing_only(); - - let created = client - .create_trail() - .with_admin(custom_admin) - .with_initial_record(Data::text("audit-trail-custom-admin"), None) - .finish() - .build_and_execute(&client) - .await? - .output; - - let cap = client.get_cap(custom_admin, created.trail_id).await; - - println!("Owned objects for custom admin {custom_admin}:"); - match cap { - Ok(cap_ref) => println!("Found accredit capability with ID: {}", cap_ref.0), - Err(e) => println!("Error finding accredit capability for custom admin: {e}"), - } - // assert!(has_admin_capability, "custom admin did not receive admin capability"); - - Ok(()) -} diff --git a/audit-trail-rs/tests/e2e/main.rs b/audit-trail-rs/tests/e2e/main.rs index dc8a4fb5..04b65814 100644 --- a/audit-trail-rs/tests/e2e/main.rs +++ b/audit-trail-rs/tests/e2e/main.rs @@ -2,6 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 mod client; -mod audit_trail_creations; mod records; mod roles; +mod trail; diff --git a/audit-trail-rs/tests/e2e/trail.rs b/audit-trail-rs/tests/e2e/trail.rs new file mode 100644 index 00000000..acc4d89b --- /dev/null +++ b/audit-trail-rs/tests/e2e/trail.rs @@ -0,0 +1,305 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use crate::client::get_funded_test_client; +use audit_trails::core::types::{ + CapabilityIssueOptions, Data, ImmutableMetadata, LockingConfig, Permission, PermissionSet, +}; +use iota_interaction::types::base_types::{IotaAddress, ObjectID}; +use product_common::core_client::CoreClient; + +/// Creates a trail and issues a MetadataAdmin capability with `UpdateMetadata` +/// permission so the owner can call `update_metadata`. +async fn create_trail_with_metadata_role( + client: &crate::client::TestClient, + initial_record: Data, + updatable_metadata: Option<&str>, + immutable_metadata: Option, +) -> anyhow::Result { + let mut builder = client.create_trail().with_initial_record(initial_record, None); + + if let Some(meta) = updatable_metadata { + builder = builder.with_updatable_metadata(meta); + } + if let Some(imm) = immutable_metadata { + builder = builder.with_trail_metadata(imm); + } + + let created = builder.finish().build_and_execute(client).await?.output; + let trail_id = created.trail_id; + let roles = client.trail(trail_id).roles(); + + // Create a dedicated MetadataAdmin role + roles + .for_role("MetadataAdmin") + .create(PermissionSet { + permissions: vec![Permission::UpdateMetadata], + }) + .build_and_execute(client) + .await?; + + // Issue a capability for it to the current signer + roles + .for_role("MetadataAdmin") + .issue_capability(CapabilityIssueOptions::default()) + .build_and_execute(client) + .await?; + + Ok(trail_id) +} + +// ===== Creation ===== + +#[tokio::test] +async fn create_trail_with_default_builder_settings() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + + let created = client + .create_trail() + .with_initial_record(Data::text("audit-trail-create-default"), None) + .finish() + .build_and_execute(&client) + .await? + .output; + + assert_eq!(created.creator, client.sender_address()); + + let on_chain = created.load_on_chain(&client).await?; + assert_eq!(on_chain.id.object_id(), &created.trail_id); + assert_eq!(on_chain.creator, client.sender_address()); + assert_eq!(on_chain.sequence_number, 1); + assert_eq!(on_chain.locking_config, LockingConfig::none()); + assert!(on_chain.immutable_metadata.is_none()); + assert!(on_chain.updatable_metadata.is_none()); + + Ok(()) +} + +#[tokio::test] +async fn create_trail_with_metadata_and_time_lock() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + + let immutable_metadata = + ImmutableMetadata::new("Trail Time Lock".to_string(), Some("immutable description".to_string())); + + let created = client + .create_trail() + .with_initial_record( + Data::text("audit-trail-create-time-lock"), + Some("initial record metadata".to_string()), + ) + .with_locking_config(LockingConfig::time_based(300)) + .with_trail_metadata(immutable_metadata.clone()) + .with_updatable_metadata("updatable metadata") + .finish() + .build_and_execute(&client) + .await? + .output; + + let on_chain = created.load_on_chain(&client).await?; + assert_eq!(on_chain.locking_config, LockingConfig::time_based(300)); + assert_eq!(on_chain.immutable_metadata, Some(immutable_metadata)); + assert_eq!(on_chain.updatable_metadata, Some("updatable metadata".to_string())); + + Ok(()) +} + +#[tokio::test] +async fn create_trail_with_bytes_and_count_lock() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + + let created = client + .create_trail() + .with_initial_record( + Data::bytes(vec![0xAA, 0xBB, 0xCC, 0xDD]), + Some("bytes metadata".to_string()), + ) + .with_locking_config(LockingConfig::count_based(3)) + .with_trail_metadata_parts("Trail Count Lock", Some("count lock description".to_string())) + .finish() + .build_and_execute(&client) + .await? + .output; + + let on_chain = created.load_on_chain(&client).await?; + assert_eq!(on_chain.locking_config, LockingConfig::count_based(3)); + assert_eq!(on_chain.sequence_number, 1); + + Ok(()) +} + +#[tokio::test] +async fn create_trail_with_custom_admin_address() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let custom_admin = IotaAddress::random_for_testing_only(); + + let created = client + .create_trail() + .with_admin(custom_admin) + .with_initial_record(Data::text("audit-trail-custom-admin"), None) + .finish() + .build_and_execute(&client) + .await? + .output; + + let cap = client.get_cap(custom_admin, created.trail_id).await; + + println!("Owned objects for custom admin {custom_admin}:"); + match cap { + Ok(cap_ref) => println!("Found accredit capability with ID: {}", cap_ref.0), + Err(e) => println!("Error finding accredit capability for custom admin: {e}"), + } + // assert!(has_admin_capability, "custom admin did not receive admin capability"); + + Ok(()) +} + +// ===== Get ===== + +#[tokio::test] +async fn get_returns_on_chain_trail() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + + let created = client + .create_trail() + .with_initial_record(Data::text("trail-get-e2e"), None) + .with_trail_metadata_parts("Get Test", Some("description".into())) + .with_updatable_metadata("initial updatable") + .finish() + .build_and_execute(&client) + .await? + .output; + + let trail = client.trail(created.trail_id); + let on_chain = trail.get().await?; + + assert_eq!(on_chain.id.object_id(), &created.trail_id); + assert_eq!(on_chain.creator, created.creator); + assert_eq!(on_chain.sequence_number, 1); + assert_eq!( + on_chain.immutable_metadata, + Some(ImmutableMetadata::new( + "Get Test".to_string(), + Some("description".to_string()) + )) + ); + assert_eq!(on_chain.updatable_metadata, Some("initial updatable".to_string())); + + Ok(()) +} + +#[tokio::test] +async fn get_trail_without_metadata() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + + let created = client + .create_trail() + .with_initial_record(Data::text("trail-no-meta-e2e"), None) + .finish() + .build_and_execute(&client) + .await? + .output; + + let on_chain = client.trail(created.trail_id).get().await?; + + assert!(on_chain.immutable_metadata.is_none()); + assert!(on_chain.updatable_metadata.is_none()); + + Ok(()) +} + +// ===== Update Metadata ===== + +#[tokio::test] +async fn update_metadata_roundtrip() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = + create_trail_with_metadata_role(&client, Data::text("trail-update-meta-e2e"), Some("before"), None).await?; + + let trail = client.trail(trail_id); + + // Verify initial value + let before = trail.get().await?; + assert_eq!(before.updatable_metadata, Some("before".to_string())); + + // Update to a new value + trail + .update_metadata(Some("after".to_string())) + .build_and_execute(&client) + .await?; + + let after = trail.get().await?; + assert_eq!(after.updatable_metadata, Some("after".to_string())); + + Ok(()) +} + +#[tokio::test] +async fn update_metadata_to_none_clears_value() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = + create_trail_with_metadata_role(&client, Data::text("trail-clear-meta-e2e"), Some("to-be-cleared"), None) + .await?; + + let trail = client.trail(trail_id); + + trail.update_metadata(None).build_and_execute(&client).await?; + + let on_chain = trail.get().await?; + assert_eq!(on_chain.updatable_metadata, None); + + Ok(()) +} + +#[tokio::test] +async fn update_metadata_multiple_times() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = create_trail_with_metadata_role(&client, Data::text("trail-multi-meta-e2e"), None, None).await?; + + let trail = client.trail(trail_id); + + // Set, then overwrite, then clear + trail + .update_metadata(Some("first".to_string())) + .build_and_execute(&client) + .await?; + + trail + .update_metadata(Some("second".to_string())) + .build_and_execute(&client) + .await?; + + trail.update_metadata(None).build_and_execute(&client).await?; + + let on_chain = trail.get().await?; + assert_eq!(on_chain.updatable_metadata, None); + + Ok(()) +} + +#[tokio::test] +async fn update_metadata_does_not_affect_immutable_metadata() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let immutable = ImmutableMetadata::new("Immutable Name".to_string(), Some("frozen".to_string())); + + let trail_id = create_trail_with_metadata_role( + &client, + Data::text("trail-immutable-check-e2e"), + Some("mutable"), + Some(immutable.clone()), + ) + .await?; + + let trail = client.trail(trail_id); + + trail + .update_metadata(Some("changed".to_string())) + .build_and_execute(&client) + .await?; + + let on_chain = trail.get().await?; + assert_eq!(on_chain.immutable_metadata, Some(immutable)); + assert_eq!(on_chain.updatable_metadata, Some("changed".to_string())); + + Ok(()) +} From cb0396c82bb8f722cd228a374b79d1726edc2696 Mon Sep 17 00:00:00 2001 From: Yasir Date: Mon, 16 Feb 2026 19:05:52 +0300 Subject: [PATCH 059/189] Refactor transaction builders and operations for records and roles - Simplified transaction builder calls in `TrailRecords`, `TrailRoles`, and `RolesOps` by removing unnecessary line breaks. - Updated `RecordsOps` to use a unified transaction building method with permission checks. - Enhanced `RolesOps` to streamline role management operations, including creating, updating, and deleting roles. - Improved error handling in `AuditTrailHandle` for fetching on-chain trail data. - Refactored permission handling in `Permission` enum to include a method for retrieving Move function names. - Cleaned up imports and organized code structure for better readability across multiple modules. - Added tests for role and record functionalities to ensure proper integration and functionality. --- audit-trail-move/Move.lock | 6 +- audit-trail-move/sources/permission.move | 2 +- audit-trail-rs/src/client/full_client.rs | 20 +- audit-trail-rs/src/core/create/operations.rs | 5 +- .../src/core/create/transactions.rs | 6 +- audit-trail-rs/src/core/mod.rs | 2 +- audit-trail-rs/src/core/operations.rs | 97 ++++++-- audit-trail-rs/src/core/records/mod.rs | 22 +- audit-trail-rs/src/core/records/operations.rs | 45 ++-- .../src/core/records/transactions.rs | 3 +- audit-trail-rs/src/core/roles/mod.rs | 55 +---- audit-trail-rs/src/core/roles/operations.rs | 213 +++++++++--------- audit-trail-rs/src/core/roles/transactions.rs | 53 ++--- audit-trail-rs/src/core/trail.rs | 37 ++- audit-trail-rs/src/core/trail/operations.rs | 23 +- audit-trail-rs/src/core/trail/transactions.rs | 5 +- audit-trail-rs/src/core/types/audit_trail.rs | 2 +- audit-trail-rs/src/core/types/event.rs | 12 +- audit-trail-rs/src/core/types/permission.rs | 22 ++ audit-trail-rs/src/core/types/record.rs | 3 +- audit-trail-rs/src/core/types/role_map.rs | 10 +- audit-trail-rs/src/core/utils.rs | 23 +- audit-trail-rs/tests/e2e/records.rs | 3 +- audit-trail-rs/tests/e2e/roles.rs | 19 +- audit-trail-rs/tests/e2e/trail.rs | 16 +- 25 files changed, 357 insertions(+), 347 deletions(-) diff --git a/audit-trail-move/Move.lock b/audit-trail-move/Move.lock index bf0606c8..64bed681 100644 --- a/audit-trail-move/Move.lock +++ b/audit-trail-move/Move.lock @@ -61,7 +61,7 @@ flavor = "iota" [env] [env.localnet] -chain-id = "b7526e0b" -original-published-id = "0x9365325d9bde0de55eaddd2ebe8e17b159b18fbeddbc9c82af0e0a01328878e1" -latest-published-id = "0x9365325d9bde0de55eaddd2ebe8e17b159b18fbeddbc9c82af0e0a01328878e1" +chain-id = "b57328de" +original-published-id = "0xfe34e5f97cde357574f7752734dce38cc0237ac14908509280baa92d435c6892" +latest-published-id = "0xfe34e5f97cde357574f7752734dce38cc0237ac14908509280baa92d435c6892" published-version = "1" diff --git a/audit-trail-move/sources/permission.move b/audit-trail-move/sources/permission.move index d98dbcea..2ba37048 100644 --- a/audit-trail-move/sources/permission.move +++ b/audit-trail-move/sources/permission.move @@ -128,7 +128,7 @@ public fun metadata_admin_permissions(): VecSet { perms } -// --------------------------- Constructor functions for all Permission variants --------------------------- +// ------- Constructor functions for all Permission variants ------------- /// Returns a permission allowing to destroy the whole Audit Trail object public fun delete_audit_trail(): Permission { diff --git a/audit-trail-rs/src/client/full_client.rs b/audit-trail-rs/src/client/full_client.rs index 1faf5df5..b16500b5 100644 --- a/audit-trail-rs/src/client/full_client.rs +++ b/audit-trail-rs/src/client/full_client.rs @@ -7,15 +7,15 @@ use std::ops::Deref; -use crate::client::read_only::AuditTrailClientReadOnly; -use crate::core::builder::AuditTrailBuilder; -use crate::core::trail::{AuditTrailFull, AuditTrailHandle, AuditTrailReadOnly}; -use crate::error::Error; use async_trait::async_trait; +#[cfg(not(target_arch = "wasm32"))] +use iota_interaction::IotaClient; use iota_interaction::types::base_types::ObjectID; use iota_interaction::types::transaction::ProgrammableTransaction; use iota_interaction::{IotaKeySignature, OptionalSync}; use iota_interaction_rust::IotaClientAdapter; +#[cfg(target_arch = "wasm32")] +use iota_interaction_ts::bindings::WasmIotaClient as IotaClient; use iota_sdk::types::base_types::IotaAddress; use iota_sdk::types::crypto::PublicKey; use product_common::core_client::{CoreClient, CoreClientReadOnly}; @@ -23,10 +23,10 @@ use product_common::network_name::NetworkName; use secret_storage::Signer; use serde::de::DeserializeOwned; -#[cfg(not(target_arch = "wasm32"))] -use iota_interaction::IotaClient; -#[cfg(target_arch = "wasm32")] -use iota_interaction_ts::bindings::WasmIotaClient as IotaClient; +use crate::client::read_only::AuditTrailClientReadOnly; +use crate::core::builder::AuditTrailBuilder; +use crate::core::trail::{AuditTrailFull, AuditTrailHandle, AuditTrailReadOnly}; +use crate::error::Error; /// A marker type indicating the absence of a signer. #[derive(Debug, Clone, Copy)] @@ -88,8 +88,8 @@ impl AuditTrailClient { /// # #[tokio::main] /// # async fn main() -> anyhow::Result<()> { /// let iota_client = iota_sdk::IotaClientBuilder::default() - /// .build_testnet() - /// .await?; + /// .build_testnet() + /// .await?; /// // No package ID is required since we are connecting to an official IOTA network. /// let audit_trail_client = AuditTrailClient::from_iota_client(iota_client, None).await?; /// # Ok(()) diff --git a/audit-trail-rs/src/core/create/operations.rs b/audit-trail-rs/src/core/create/operations.rs index 3ca209ee..4cc2c3dd 100644 --- a/audit-trail-rs/src/core/create/operations.rs +++ b/audit-trail-rs/src/core/create/operations.rs @@ -3,13 +3,12 @@ use iota_interaction::ident_str; use iota_interaction::types::base_types::{IotaAddress, ObjectID}; -use iota_interaction::types::transaction::Argument; -use iota_interaction::types::transaction::ProgrammableTransaction; +use iota_interaction::types::programmable_transaction_builder::ProgrammableTransactionBuilder; +use iota_interaction::types::transaction::{Argument, ProgrammableTransaction}; use crate::core::types::{Data, ImmutableMetadata, LockingConfig}; use crate::core::utils; use crate::error::Error; -use iota_interaction::types::programmable_transaction_builder::ProgrammableTransactionBuilder; pub(super) struct CreateOps; diff --git a/audit-trail-rs/src/core/create/transactions.rs b/audit-trail-rs/src/core/create/transactions.rs index 38bb261c..a9ffbcca 100644 --- a/audit-trail-rs/src/core/create/transactions.rs +++ b/audit-trail-rs/src/core/create/transactions.rs @@ -2,24 +2,22 @@ // SPDX-License-Identifier: Apache-2.0 use async_trait::async_trait; -use iota_interaction::IotaClientTrait; -use iota_interaction::OptionalSync; use iota_interaction::rpc_types::{ IotaData as _, IotaObjectDataOptions, IotaTransactionBlockEffects, IotaTransactionBlockEvents, }; use iota_interaction::types::base_types::ObjectID; use iota_interaction::types::transaction::ProgrammableTransaction; +use iota_interaction::{IotaClientTrait, OptionalSync}; use iota_sdk::types::base_types::IotaAddress; use product_common::core_client::CoreClientReadOnly; use product_common::transaction::transaction_builder::Transaction; use tokio::sync::OnceCell; +use super::operations::CreateOps; use crate::core::builder::AuditTrailBuilder; use crate::core::types::{AuditTrailCreated, Event, OnChainAuditTrail}; use crate::error::Error; -use super::operations::CreateOps; - /// Output of a create trail transaction. #[derive(Debug, Clone)] pub struct TrailCreated { diff --git a/audit-trail-rs/src/core/mod.rs b/audit-trail-rs/src/core/mod.rs index 8794d361..61bb2078 100644 --- a/audit-trail-rs/src/core/mod.rs +++ b/audit-trail-rs/src/core/mod.rs @@ -7,8 +7,8 @@ pub mod builder; pub mod create; pub mod locking; pub(crate) mod operations; -pub(crate) mod utils; pub mod records; pub mod roles; pub mod trail; pub mod types; +pub(crate) mod utils; diff --git a/audit-trail-rs/src/core/operations.rs b/audit-trail-rs/src/core/operations.rs index 457a837e..a2aac138 100644 --- a/audit-trail-rs/src/core/operations.rs +++ b/audit-trail-rs/src/core/operations.rs @@ -1,23 +1,51 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +use std::collections::HashSet; use std::str::FromStr; -use iota_interaction::types::TypeTag; +use iota_interaction::rpc_types::{ + IotaData as _, IotaObjectDataOptions, IotaTransactionBlockEffects, IotaTransactionBlockEvents, +}; use iota_interaction::types::base_types::{IotaAddress, ObjectID, ObjectRef}; use iota_interaction::types::programmable_transaction_builder::ProgrammableTransactionBuilder; use iota_interaction::types::transaction::{Argument, ObjectArg, ProgrammableTransaction}; -use iota_interaction::{OptionalSync, ident_str}; +use iota_interaction::types::{Identifier, TypeTag}; +use iota_interaction::{IotaClientTrait, OptionalSync, ident_str}; use product_common::core_client::CoreClientReadOnly; -use crate::core::types::Capability; +use crate::core::types::{Capability, OnChainAuditTrail, Permission}; use crate::core::utils; use crate::error::Error; -pub(crate) async fn build_trail_transaction_for_owner( +pub async fn get_audit_trail(trail_id: ObjectID, client: &C) -> Result +where + C: CoreClientReadOnly + OptionalSync, +{ + let data = client + .client_adapter() + .read_api() + .get_object_with_options(trail_id, IotaObjectDataOptions::bcs_lossless()) + .await + .map_err(|e| Error::UnexpectedApiResponse(format!("failed to fetch trail {} object; {e}", trail_id)))? + .data + .ok_or_else(|| Error::UnexpectedApiResponse(format!("trail {} data not found", trail_id)))?; + + data.bcs + .ok_or_else(|| Error::UnexpectedApiResponse(format!("trail {} missing bcs object content", trail_id)))? + .try_into_move() + .ok_or_else(|| Error::UnexpectedApiResponse(format!("trail {} bcs content is not a move object", trail_id)))? + .deserialize() + .map_err(|e| Error::UnexpectedApiResponse(format!("failed to decode trail {} bcs data; {e}", trail_id))) +} + +/// Builds a trail transaction by auto-discovering the right capability for the +/// given owner and required permission via the trail's on-chain RoleMap. +pub(crate) async fn build_trail_transaction( client: &C, trail_id: ObjectID, owner: IotaAddress, + permission: Permission, method: impl AsRef, additional_args: F, ) -> Result @@ -25,10 +53,49 @@ where F: FnOnce(&mut ProgrammableTransactionBuilder, &TypeTag) -> Result, Error>, C: CoreClientReadOnly + OptionalSync, { - let cap_ref = get_capability_ref(client, owner, trail_id).await?; + let trail = get_audit_trail(trail_id, client).await?; + + let cap_ref = find_capable_cap(client, owner, trail_id, &trail, permission).await?; build_trail_transaction_with_cap_ref(client, trail_id, cap_ref, method, additional_args).await } +/// Finds a capability owned by `owner` whose role has the required permission +/// according to the trail's RoleMap. +async fn find_capable_cap( + client: &C, + owner: IotaAddress, + trail_id: ObjectID, + trail: &OnChainAuditTrail, + permission: Permission, +) -> Result +where + C: CoreClientReadOnly + OptionalSync, +{ + let valid_roles: HashSet<&String> = trail + .roles + .roles + .iter() + .filter(|(_, perms)| perms.contains(&permission)) + .map(|(name, _)| name) + .collect(); + + let cap: Capability = client + .find_object_for_address(owner, |cap: &Capability| { + cap.target_key == trail_id && valid_roles.contains(&cap.role) + }) + .await + .map_err(|e| Error::RpcError(e.to_string()))? + .ok_or_else(|| { + Error::InvalidArgument(format!( + "no capability with {:?} permission found for owner {owner} and trail {trail_id}", + permission + )) + })?; + + let object_id = *cap.id.object_id(); + utils::get_object_ref_by_id(client, &object_id).await +} + pub(crate) async fn build_trail_transaction_with_cap_ref( client: &C, trail_id: ObjectID, @@ -55,7 +122,7 @@ where args.extend(additional_args(&mut ptb, &type_tag)?); - let function = iota_interaction::types::Identifier::from_str(method.as_ref()) + let function = Identifier::from_str(method.as_ref()) .map_err(|e| Error::InvalidArgument(format!("Invalid method name '{}': {e}", method.as_ref())))?; ptb.programmable_move_call(client.package_id(), ident_str!("main").into(), function, tag, args); @@ -63,24 +130,6 @@ where Ok(ptb.finish()) } -pub(crate) async fn get_capability_ref( - client: &C, - owner: IotaAddress, - trail_id: ObjectID, -) -> Result -where - C: CoreClientReadOnly + OptionalSync, -{ - let cap: Capability = client - .find_object_for_address(owner, |cap: &Capability| cap.target_key == trail_id) - .await - .map_err(|e| Error::RpcError(e.to_string()))? - .ok_or_else(|| Error::InvalidArgument(format!("no capability found for owner {owner} and trail {trail_id}")))?; - - let object_id = *cap.id.object_id(); - utils::get_object_ref_by_id(client, &object_id).await -} - pub(crate) async fn build_read_only_transaction( client: &C, trail_id: ObjectID, diff --git a/audit-trail-rs/src/core/records/mod.rs b/audit-trail-rs/src/core/records/mod.rs index ef8f97fe..4ced4523 100644 --- a/audit-trail-rs/src/core/records/mod.rs +++ b/audit-trail-rs/src/core/records/mod.rs @@ -1,6 +1,8 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +use std::collections::HashMap; + use iota_interaction::move_types::annotated_value::MoveValue; use iota_interaction::rpc_types::IotaMoveValue; use iota_interaction::types::TypeTag; @@ -11,8 +13,8 @@ use iota_interaction::{IotaClientTrait, IotaKeySignature, OptionalSync}; use product_common::core_client::{CoreClient, CoreClientReadOnly}; use product_common::transaction::transaction_builder::TransactionBuilder; use secret_storage::Signer; -use serde::{Deserialize, de::DeserializeOwned}; -use std::collections::HashMap; +use serde::Deserialize; +use serde::de::DeserializeOwned; use crate::core::trail::{AuditTrailFull, AuditTrailReadOnly}; use crate::core::types::{Data, OnChainAuditTrail, PaginatedRecord, Record}; @@ -21,9 +23,10 @@ use crate::error::Error; mod operations; mod transactions; -use self::operations::RecordsOps; pub use transactions::{AddRecord, DeleteRecord}; +use self::operations::RecordsOps; + #[derive(Debug, Clone)] pub struct TrailRecords<'a, C, D = Data> { pub(crate) client: &'a C, @@ -56,12 +59,7 @@ impl<'a, C, D> TrailRecords<'a, C, D> { D: Into, { let owner = self.client.sender_address(); - TransactionBuilder::new(AddRecord::new( - self.trail_id, - owner, - data.into(), - metadata, - )) + TransactionBuilder::new(AddRecord::new(self.trail_id, owner, data.into(), metadata)) } pub fn delete(&self, sequence_number: u64) -> TransactionBuilder @@ -70,11 +68,7 @@ impl<'a, C, D> TrailRecords<'a, C, D> { S: Signer + OptionalSync, { let owner = self.client.sender_address(); - TransactionBuilder::new(DeleteRecord::new( - self.trail_id, - owner, - sequence_number, - )) + TransactionBuilder::new(DeleteRecord::new(self.trail_id, owner, sequence_number)) } pub async fn correct(&self, _replaces: Vec, _data: D, _metadata: Option) -> Result<(), Error> diff --git a/audit-trail-rs/src/core/records/operations.rs b/audit-trail-rs/src/core/records/operations.rs index f353ab65..38c656da 100644 --- a/audit-trail-rs/src/core/records/operations.rs +++ b/audit-trail-rs/src/core/records/operations.rs @@ -1,14 +1,13 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +use iota_interaction::OptionalSync; use iota_interaction::types::base_types::{IotaAddress, ObjectID}; use iota_interaction::types::transaction::ProgrammableTransaction; -use iota_interaction::OptionalSync; use product_common::core_client::CoreClientReadOnly; -use crate::core::operations; -use crate::core::types::Data; -use crate::core::utils; +use crate::core::types::{Data, Permission}; +use crate::core::{operations, utils}; use crate::error::Error; pub(super) struct RecordsOps; @@ -24,14 +23,21 @@ impl RecordsOps { where C: CoreClientReadOnly + OptionalSync, { - operations::build_trail_transaction_for_owner(client, trail_id, owner, "add_record", |ptb, trail_tag| { - data.ensure_matches_tag(trail_tag)?; + operations::build_trail_transaction( + client, + trail_id, + owner, + Permission::AddRecord, + "add_record", + |ptb, trail_tag| { + data.ensure_matches_tag(trail_tag)?; - let data_arg = data.to_ptb(ptb, "stored_data")?; - let metadata = utils::ptb_pure(ptb, "record_metadata", record_metadata)?; - let clock = utils::get_clock_ref(ptb); - Ok(vec![data_arg, metadata, clock]) - }) + let data_arg = data.to_ptb(ptb, "stored_data")?; + let metadata = utils::ptb_pure(ptb, "record_metadata", record_metadata)?; + let clock = utils::get_clock_ref(ptb); + Ok(vec![data_arg, metadata, clock]) + }, + ) .await } @@ -44,11 +50,18 @@ impl RecordsOps { where C: CoreClientReadOnly + OptionalSync, { - operations::build_trail_transaction_for_owner(client, trail_id, owner, "delete_record", |ptb, _| { - let seq = utils::ptb_pure(ptb, "sequence_number", sequence_number)?; - let clock = utils::get_clock_ref(ptb); - Ok(vec![seq, clock]) - }) + operations::build_trail_transaction( + client, + trail_id, + owner, + Permission::DeleteRecord, + "delete_record", + |ptb, _| { + let seq = utils::ptb_pure(ptb, "sequence_number", sequence_number)?; + let clock = utils::get_clock_ref(ptb); + Ok(vec![seq, clock]) + }, + ) .await } diff --git a/audit-trail-rs/src/core/records/transactions.rs b/audit-trail-rs/src/core/records/transactions.rs index 3b7e3248..5f115a63 100644 --- a/audit-trail-rs/src/core/records/transactions.rs +++ b/audit-trail-rs/src/core/records/transactions.rs @@ -10,11 +10,10 @@ use product_common::core_client::CoreClientReadOnly; use product_common::transaction::transaction_builder::Transaction; use tokio::sync::OnceCell; +use super::operations::RecordsOps; use crate::core::types::{Data, Event, RecordAdded, RecordDeleted}; use crate::error::Error; -use super::operations::RecordsOps; - // ===== AddRecord ===== #[derive(Debug, Clone)] diff --git a/audit-trail-rs/src/core/roles/mod.rs b/audit-trail-rs/src/core/roles/mod.rs index 235e7b90..b92d7067 100644 --- a/audit-trail-rs/src/core/roles/mod.rs +++ b/audit-trail-rs/src/core/roles/mod.rs @@ -41,11 +41,7 @@ impl<'a, C> TrailRoles<'a, C> { S: Signer + OptionalSync, { let owner = self.client.sender_address(); - TransactionBuilder::new(RevokeCapability::new( - self.trail_id, - owner, - capability_id, - )) + TransactionBuilder::new(RevokeCapability::new(self.trail_id, owner, capability_id)) } /// Destroys a capability object. @@ -55,11 +51,7 @@ impl<'a, C> TrailRoles<'a, C> { S: Signer + OptionalSync, { let owner = self.client.sender_address(); - TransactionBuilder::new(DestroyCapability::new( - self.trail_id, - owner, - capability_id, - )) + TransactionBuilder::new(DestroyCapability::new(self.trail_id, owner, capability_id)) } /// Destroys an initial admin capability (self-service, no auth cap required). @@ -71,10 +63,7 @@ impl<'a, C> TrailRoles<'a, C> { C: AuditTrailFull + CoreClient, S: Signer + OptionalSync, { - TransactionBuilder::new(DestroyInitialAdminCapability::new( - self.trail_id, - capability_id, - )) + TransactionBuilder::new(DestroyInitialAdminCapability::new(self.trail_id, capability_id)) } /// Revokes an initial admin capability by ID. @@ -87,11 +76,7 @@ impl<'a, C> TrailRoles<'a, C> { S: Signer + OptionalSync, { let owner = self.client.sender_address(); - TransactionBuilder::new(RevokeInitialAdminCapability::new( - self.trail_id, - owner, - capability_id, - )) + TransactionBuilder::new(RevokeInitialAdminCapability::new(self.trail_id, owner, capability_id)) } } @@ -118,30 +103,17 @@ impl<'a, C> RoleHandle<'a, C> { S: Signer + OptionalSync, { let owner = self.client.sender_address(); - TransactionBuilder::new(CreateRole::new( - self.trail_id, - owner, - self.name.clone(), - permissions, - )) + TransactionBuilder::new(CreateRole::new(self.trail_id, owner, self.name.clone(), permissions)) } /// Issues a capability for this role using optional restrictions. - pub fn issue_capability( - &self, - options: CapabilityIssueOptions, - ) -> TransactionBuilder + pub fn issue_capability(&self, options: CapabilityIssueOptions) -> TransactionBuilder where C: AuditTrailFull + CoreClient, S: Signer + OptionalSync, { let owner = self.client.sender_address(); - TransactionBuilder::new(IssueCapability::new( - self.trail_id, - owner, - self.name.clone(), - options, - )) + TransactionBuilder::new(IssueCapability::new(self.trail_id, owner, self.name.clone(), options)) } /// Updates permissions for this role. @@ -151,12 +123,7 @@ impl<'a, C> RoleHandle<'a, C> { S: Signer + OptionalSync, { let owner = self.client.sender_address(); - TransactionBuilder::new(UpdateRole::new( - self.trail_id, - owner, - self.name.clone(), - permissions, - )) + TransactionBuilder::new(UpdateRole::new(self.trail_id, owner, self.name.clone(), permissions)) } /// Deletes this role. @@ -166,10 +133,6 @@ impl<'a, C> RoleHandle<'a, C> { S: Signer + OptionalSync, { let owner = self.client.sender_address(); - TransactionBuilder::new(DeleteRole::new( - self.trail_id, - owner, - self.name.clone(), - )) + TransactionBuilder::new(DeleteRole::new(self.trail_id, owner, self.name.clone())) } } diff --git a/audit-trail-rs/src/core/roles/operations.rs b/audit-trail-rs/src/core/roles/operations.rs index 770b5ea9..d2eade45 100644 --- a/audit-trail-rs/src/core/roles/operations.rs +++ b/audit-trail-rs/src/core/roles/operations.rs @@ -1,18 +1,16 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use iota_interaction::types::base_types::{IotaAddress, ObjectID}; +use std::str::FromStr; + use iota_interaction::types::TypeTag; -use iota_interaction::types::transaction::Command; -use iota_interaction::types::transaction::ObjectArg; -use iota_interaction::types::transaction::ProgrammableTransaction; +use iota_interaction::types::base_types::{IotaAddress, ObjectID}; +use iota_interaction::types::transaction::{Command, ObjectArg, ProgrammableTransaction}; use iota_interaction::{OptionalSync, ident_str}; use product_common::core_client::CoreClientReadOnly; -use std::str::FromStr; -use crate::core::operations; -use crate::core::types::{Capability, CapabilityIssueOptions, Permission, PermissionSet}; -use crate::core::utils; +use crate::core::types::{CapabilityIssueOptions, Permission, PermissionSet}; +use crate::core::{operations, utils}; use crate::error::Error; pub(super) struct RolesOps; @@ -28,21 +26,27 @@ impl RolesOps { where C: CoreClientReadOnly + OptionalSync, { - let admin_cap_ref = Self::get_admin_capability_ref(client, owner, trail_id).await?; - operations::build_trail_transaction_with_cap_ref(client, trail_id, admin_cap_ref, "create_role", |ptb, _| { - let role = utils::ptb_pure(ptb, "role", name)?; - let perms_vec = Self::permissions_to_vec(ptb, client.package_id(), permissions.permissions); - let perms = ptb.programmable_move_call( - client.package_id(), - ident_str!("permission").into(), - ident_str!("from_vec").into(), - vec![], - vec![perms_vec], - ); - let clock = utils::get_clock_ref(ptb); + operations::build_trail_transaction( + client, + trail_id, + owner, + Permission::AddRoles, + "create_role", + |ptb, _| { + let role = utils::ptb_pure(ptb, "role", name)?; + let perms_vec = Self::permissions_to_vec(ptb, client.package_id(), permissions.permissions); + let perms = ptb.programmable_move_call( + client.package_id(), + ident_str!("permission").into(), + ident_str!("from_vec").into(), + vec![], + vec![perms_vec], + ); + let clock = utils::get_clock_ref(ptb); - Ok(vec![role, perms, clock]) - }) + Ok(vec![role, perms, clock]) + }, + ) .await } @@ -56,21 +60,27 @@ impl RolesOps { where C: CoreClientReadOnly + OptionalSync, { - let admin_cap_ref = Self::get_admin_capability_ref(client, owner, trail_id).await?; - operations::build_trail_transaction_with_cap_ref(client, trail_id, admin_cap_ref, "update_role_permissions", |ptb, _| { - let role = utils::ptb_pure(ptb, "role", name)?; - let perms_vec = Self::permissions_to_vec(ptb, client.package_id(), permissions.permissions); - let perms = ptb.programmable_move_call( - client.package_id(), - ident_str!("permission").into(), - ident_str!("from_vec").into(), - vec![], - vec![perms_vec], - ); - let clock = utils::get_clock_ref(ptb); + operations::build_trail_transaction( + client, + trail_id, + owner, + Permission::UpdateRoles, + "update_role_permissions", + |ptb, _| { + let role = utils::ptb_pure(ptb, "role", name)?; + let perms_vec = Self::permissions_to_vec(ptb, client.package_id(), permissions.permissions); + let perms = ptb.programmable_move_call( + client.package_id(), + ident_str!("permission").into(), + ident_str!("from_vec").into(), + vec![], + vec![perms_vec], + ); + let clock = utils::get_clock_ref(ptb); - Ok(vec![role, perms, clock]) - }) + Ok(vec![role, perms, clock]) + }, + ) .await } @@ -93,23 +103,8 @@ impl RolesOps { package_id: ObjectID, permission: Permission, ) -> iota_interaction::types::transaction::Argument { - let function = match permission { - Permission::DeleteAuditTrail => ident_str!("delete_audit_trail").into(), - Permission::AddRecord => ident_str!("add_record").into(), - Permission::DeleteRecord => ident_str!("delete_record").into(), - Permission::CorrectRecord => ident_str!("correct_record").into(), - Permission::UpdateLockingConfig => ident_str!("update_locking_config").into(), - Permission::UpdateLockingConfigForDeleteRecord => ident_str!("update_locking_config_for_delete_record").into(), - Permission::UpdateLockingConfigForDeleteTrail => ident_str!("update_locking_config_for_delete_trail").into(), - Permission::AddRoles => ident_str!("add_roles").into(), - Permission::UpdateRoles => ident_str!("update_roles").into(), - Permission::DeleteRoles => ident_str!("delete_roles").into(), - Permission::AddCapabilities => ident_str!("add_capabilities").into(), - Permission::RevokeCapabilities => ident_str!("revoke_capabilities").into(), - Permission::UpdateMetadata => ident_str!("update_metadata").into(), - Permission::DeleteMetadata => ident_str!("delete_metadata").into(), - }; - + let function = iota_interaction::types::Identifier::from_str(permission.move_function_name()) + .expect("invalid permission function name"); ptb.programmable_move_call(package_id, ident_str!("permission").into(), function, vec![], vec![]) } @@ -122,13 +117,19 @@ impl RolesOps { where C: CoreClientReadOnly + OptionalSync, { - let admin_cap_ref = Self::get_admin_capability_ref(client, owner, trail_id).await?; - operations::build_trail_transaction_with_cap_ref(client, trail_id, admin_cap_ref, "delete_role", |ptb, _| { - let role = utils::ptb_pure(ptb, "role", name)?; - let clock = utils::get_clock_ref(ptb); + operations::build_trail_transaction( + client, + trail_id, + owner, + Permission::DeleteRoles, + "delete_role", + |ptb, _| { + let role = utils::ptb_pure(ptb, "role", name)?; + let clock = utils::get_clock_ref(ptb); - Ok(vec![role, clock]) - }) + Ok(vec![role, clock]) + }, + ) .await } @@ -142,16 +143,22 @@ impl RolesOps { where C: CoreClientReadOnly + OptionalSync, { - let admin_cap_ref = Self::get_admin_capability_ref(client, owner, trail_id).await?; - operations::build_trail_transaction_with_cap_ref(client, trail_id, admin_cap_ref, "new_capability", |ptb, _| { - let role = utils::ptb_pure(ptb, "role", role_name)?; - let issued_to = utils::ptb_pure(ptb, "issued_to", options.issued_to)?; - let valid_from = utils::ptb_pure(ptb, "valid_from", options.valid_from_ms)?; - let valid_until = utils::ptb_pure(ptb, "valid_until", options.valid_until_ms)?; - let clock = utils::get_clock_ref(ptb); + operations::build_trail_transaction( + client, + trail_id, + owner, + Permission::AddCapabilities, + "new_capability", + |ptb, _| { + let role = utils::ptb_pure(ptb, "role", role_name)?; + let issued_to = utils::ptb_pure(ptb, "issued_to", options.issued_to)?; + let valid_from = utils::ptb_pure(ptb, "valid_from", options.valid_from_ms)?; + let valid_until = utils::ptb_pure(ptb, "valid_until", options.valid_until_ms)?; + let clock = utils::get_clock_ref(ptb); - Ok(vec![role, issued_to, valid_from, valid_until, clock]) - }) + Ok(vec![role, issued_to, valid_from, valid_until, clock]) + }, + ) .await } @@ -164,13 +171,19 @@ impl RolesOps { where C: CoreClientReadOnly + OptionalSync, { - let admin_cap_ref = Self::get_admin_capability_ref(client, owner, trail_id).await?; - operations::build_trail_transaction_with_cap_ref(client, trail_id, admin_cap_ref, "revoke_capability", |ptb, _| { - let cap = utils::ptb_pure(ptb, "capability_id", capability_id)?; - let clock = utils::get_clock_ref(ptb); + operations::build_trail_transaction( + client, + trail_id, + owner, + Permission::RevokeCapabilities, + "revoke_capability", + |ptb, _| { + let cap = utils::ptb_pure(ptb, "capability_id", capability_id)?; + let clock = utils::get_clock_ref(ptb); - Ok(vec![cap, clock]) - }) + Ok(vec![cap, clock]) + }, + ) .await } @@ -183,17 +196,23 @@ impl RolesOps { where C: CoreClientReadOnly + OptionalSync, { - let admin_cap_ref = Self::get_admin_capability_ref(client, owner, trail_id).await?; let capability_ref = utils::get_object_ref_by_id(client, &capability_id).await?; - operations::build_trail_transaction_with_cap_ref(client, trail_id, admin_cap_ref, "destroy_capability", |ptb, _| { - let cap_to_destroy = ptb - .obj(ObjectArg::ImmOrOwnedObject(capability_ref)) - .map_err(|e| Error::InvalidArgument(format!("Failed to create capability argument: {e}")))?; - let clock = utils::get_clock_ref(ptb); + operations::build_trail_transaction( + client, + trail_id, + owner, + Permission::RevokeCapabilities, + "destroy_capability", + |ptb, _| { + let cap_to_destroy = ptb + .obj(ObjectArg::ImmOrOwnedObject(capability_ref)) + .map_err(|e| Error::InvalidArgument(format!("Failed to create capability argument: {e}")))?; + let clock = utils::get_clock_ref(ptb); - Ok(vec![cap_to_destroy, clock]) - }) + Ok(vec![cap_to_destroy, clock]) + }, + ) .await } @@ -225,11 +244,11 @@ impl RolesOps { where C: CoreClientReadOnly + OptionalSync, { - let admin_cap_ref = Self::get_admin_capability_ref(client, owner, trail_id).await?; - operations::build_trail_transaction_with_cap_ref( + operations::build_trail_transaction( client, trail_id, - admin_cap_ref, + owner, + Permission::RevokeCapabilities, "revoke_initial_admin_capability", |ptb, _| { let cap = utils::ptb_pure(ptb, "capability_id", capability_id)?; @@ -240,28 +259,4 @@ impl RolesOps { ) .await } - - async fn get_admin_capability_ref( - client: &C, - owner: IotaAddress, - trail_id: ObjectID, - ) -> Result - where - C: CoreClientReadOnly + OptionalSync, - { - let admin_cap: Capability = client - .find_object_for_address(owner, |cap: &Capability| { - cap.target_key == trail_id && cap.role == "Admin" - }) - .await - .map_err(|e| Error::RpcError(e.to_string()))? - .ok_or_else(|| { - Error::InvalidArgument(format!( - "no admin capability found for owner {owner} and trail {trail_id}" - )) - })?; - - let object_id = *admin_cap.id.object_id(); - utils::get_object_ref_by_id(client, &object_id).await - } } diff --git a/audit-trail-rs/src/core/roles/transactions.rs b/audit-trail-rs/src/core/roles/transactions.rs index 6ee930af..f0278870 100644 --- a/audit-trail-rs/src/core/roles/transactions.rs +++ b/audit-trail-rs/src/core/roles/transactions.rs @@ -2,22 +2,21 @@ // SPDX-License-Identifier: Apache-2.0 use async_trait::async_trait; +use iota_interaction::OptionalSync; use iota_interaction::rpc_types::{IotaTransactionBlockEffects, IotaTransactionBlockEvents}; use iota_interaction::types::base_types::{IotaAddress, ObjectID}; use iota_interaction::types::transaction::ProgrammableTransaction; -use iota_interaction::OptionalSync; use product_common::core_client::CoreClientReadOnly; use product_common::transaction::transaction_builder::Transaction; use tokio::sync::OnceCell; +use super::operations::RolesOps; use crate::core::types::{ CapabilityDestroyed, CapabilityIssueOptions, CapabilityIssued, CapabilityRevoked, Event, PermissionSet, RoleCreated, RoleDeleted, RoleUpdated, }; use crate::error::Error; -use super::operations::RolesOps; - // ===== CreateRole ===== #[derive(Debug, Clone)] @@ -77,13 +76,13 @@ impl Transaction for CreateRole { where C: CoreClientReadOnly + OptionalSync, { - for data in &events.data { - if let Ok(event) = serde_json::from_value::>(data.parsed_json.clone()) { - return Ok(event.data); - } - } + let event = events + .data + .iter() + .find_map(|data| serde_json::from_value::>(data.parsed_json.clone()).ok()) + .ok_or_else(|| Error::UnexpectedApiResponse("RoleCreated event not found".to_string()))?; - Err(Error::UnexpectedApiResponse("RoleCreated event not found".to_string())) + Ok(event.data) } async fn apply(mut self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result @@ -233,11 +232,7 @@ impl Transaction for DeleteRole { Err(Error::UnexpectedApiResponse("RoleDeleted event not found".to_string())) } - async fn apply( - mut self, - _: &mut IotaTransactionBlockEffects, - _: &C, - ) -> Result + async fn apply(mut self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result where C: CoreClientReadOnly + OptionalSync, { @@ -317,11 +312,7 @@ impl Transaction for IssueCapability { )) } - async fn apply( - mut self, - _: &mut IotaTransactionBlockEffects, - _: &C, - ) -> Result + async fn apply(mut self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result where C: CoreClientReadOnly + OptionalSync, { @@ -392,11 +383,7 @@ impl Transaction for RevokeCapability { )) } - async fn apply( - mut self, - _: &mut IotaTransactionBlockEffects, - _: &C, - ) -> Result + async fn apply(mut self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result where C: CoreClientReadOnly + OptionalSync, { @@ -467,11 +454,7 @@ impl Transaction for DestroyCapability { )) } - async fn apply( - mut self, - _: &mut IotaTransactionBlockEffects, - _: &C, - ) -> Result + async fn apply(mut self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result where C: CoreClientReadOnly + OptionalSync, { @@ -540,11 +523,7 @@ impl Transaction for DestroyInitialAdminCapability { )) } - async fn apply( - mut self, - _: &mut IotaTransactionBlockEffects, - _: &C, - ) -> Result + async fn apply(mut self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result where C: CoreClientReadOnly + OptionalSync, { @@ -615,11 +594,7 @@ impl Transaction for RevokeInitialAdminCapability { )) } - async fn apply( - mut self, - _: &mut IotaTransactionBlockEffects, - _: &C, - ) -> Result + async fn apply(mut self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result where C: CoreClientReadOnly + OptionalSync, { diff --git a/audit-trail-rs/src/core/trail.rs b/audit-trail-rs/src/core/trail.rs index 3114633d..8d5467b3 100644 --- a/audit-trail-rs/src/core/trail.rs +++ b/audit-trail-rs/src/core/trail.rs @@ -1,9 +1,12 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use iota_interaction::{IotaKeySignature, OptionalSync}; +use iota_interaction::rpc_types::{ + IotaData as _, IotaObjectDataOptions, IotaTransactionBlockEffects, IotaTransactionBlockEvents, +}; use iota_interaction::types::base_types::ObjectID; use iota_interaction::types::transaction::ProgrammableTransaction; +use iota_interaction::{IotaClientTrait, IotaKeySignature, OptionalSync}; use product_common::core_client::{CoreClient, CoreClientReadOnly}; use product_common::transaction::transaction_builder::TransactionBuilder; use secret_storage::Signer; @@ -49,12 +52,26 @@ impl<'a, C> AuditTrailHandle<'a, C> { where C: AuditTrailReadOnly, { - self.client.get_object_by_id(self.trail_id).await.map_err(|err| { - Error::UnexpectedApiResponse(format!( - "failed to load on-chain trail {}; {err}", - self.trail_id - )) - }) + let data = self + .client + .client_adapter() + .read_api() + .get_object_with_options(self.trail_id, IotaObjectDataOptions::bcs_lossless()) + .await + .map_err(|e| Error::UnexpectedApiResponse(format!("failed to fetch trail {} object; {e}", self.trail_id)))? + .data + .ok_or_else(|| Error::UnexpectedApiResponse(format!("trail {} data not found", self.trail_id)))?; + + data.bcs + .ok_or_else(|| Error::UnexpectedApiResponse(format!("trail {} missing bcs object content", self.trail_id)))? + .try_into_move() + .ok_or_else(|| { + Error::UnexpectedApiResponse(format!("trail {} bcs content is not a move object", self.trail_id)) + })? + .deserialize() + .map_err(|e| { + Error::UnexpectedApiResponse(format!("failed to decode trail {} bcs data; {e}", self.trail_id)) + }) } /// Updates the trail's updatable metadata. @@ -64,11 +81,7 @@ impl<'a, C> AuditTrailHandle<'a, C> { S: Signer + OptionalSync, { let owner = self.client.sender_address(); - TransactionBuilder::new(UpdateMetadata::new( - self.trail_id, - owner, - metadata, - )) + TransactionBuilder::new(UpdateMetadata::new(self.trail_id, owner, metadata)) } pub fn records(&self) -> TrailRecords<'a, C, Data> { diff --git a/audit-trail-rs/src/core/trail/operations.rs b/audit-trail-rs/src/core/trail/operations.rs index 3b720f91..d9738f45 100644 --- a/audit-trail-rs/src/core/trail/operations.rs +++ b/audit-trail-rs/src/core/trail/operations.rs @@ -1,13 +1,13 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +use iota_interaction::OptionalSync; use iota_interaction::types::base_types::{IotaAddress, ObjectID}; use iota_interaction::types::transaction::ProgrammableTransaction; -use iota_interaction::OptionalSync; use product_common::core_client::CoreClientReadOnly; -use crate::core::operations; -use crate::core::utils; +use crate::core::types::Permission; +use crate::core::{operations, utils}; use crate::error::Error; pub(super) struct TrailOps; @@ -22,11 +22,18 @@ impl TrailOps { where C: CoreClientReadOnly + OptionalSync, { - operations::build_trail_transaction_for_owner(client, trail_id, owner, "update_metadata", |ptb, _| { - let metadata_arg = utils::ptb_pure(ptb, "new_metadata", metadata)?; - let clock = utils::get_clock_ref(ptb); - Ok(vec![metadata_arg, clock]) - }) + operations::build_trail_transaction( + client, + trail_id, + owner, + Permission::UpdateMetadata, + "update_metadata", + |ptb, _| { + let metadata_arg = utils::ptb_pure(ptb, "new_metadata", metadata)?; + let clock = utils::get_clock_ref(ptb); + Ok(vec![metadata_arg, clock]) + }, + ) .await } } diff --git a/audit-trail-rs/src/core/trail/transactions.rs b/audit-trail-rs/src/core/trail/transactions.rs index 06a8a266..435d5165 100644 --- a/audit-trail-rs/src/core/trail/transactions.rs +++ b/audit-trail-rs/src/core/trail/transactions.rs @@ -2,17 +2,16 @@ // SPDX-License-Identifier: Apache-2.0 use async_trait::async_trait; +use iota_interaction::OptionalSync; use iota_interaction::rpc_types::{IotaTransactionBlockEffects, IotaTransactionBlockEvents}; use iota_interaction::types::base_types::{IotaAddress, ObjectID}; use iota_interaction::types::transaction::ProgrammableTransaction; -use iota_interaction::OptionalSync; use product_common::core_client::CoreClientReadOnly; use product_common::transaction::transaction_builder::Transaction; use tokio::sync::OnceCell; -use crate::error::Error; - use super::operations::TrailOps; +use crate::error::Error; // ===== UpdateMetadata ===== diff --git a/audit-trail-rs/src/core/types/audit_trail.rs b/audit-trail-rs/src/core/types/audit_trail.rs index 55d2a6a7..2a33c807 100644 --- a/audit-trail-rs/src/core/types/audit_trail.rs +++ b/audit-trail-rs/src/core/types/audit_trail.rs @@ -8,6 +8,7 @@ use iota_interaction::types::TypeTag; use iota_interaction::types::base_types::{IotaAddress, ObjectID}; use iota_interaction::types::collection_types::LinkedTable; use iota_interaction::types::id::UID; +use iota_interaction::types::programmable_transaction_builder::ProgrammableTransactionBuilder as Ptb; use iota_interaction::types::transaction::Argument; use serde::{Deserialize, Serialize}; @@ -15,7 +16,6 @@ use super::locking::LockingConfig; use super::role_map::RoleMap; use crate::core::utils; use crate::error::Error; -use iota_interaction::types::programmable_transaction_builder::ProgrammableTransactionBuilder as Ptb; /// An audit trail stored on-chain. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] diff --git a/audit-trail-rs/src/core/types/event.rs b/audit-trail-rs/src/core/types/event.rs index 4d91ff2a..0729fca0 100644 --- a/audit-trail-rs/src/core/types/event.rs +++ b/audit-trail-rs/src/core/types/event.rs @@ -1,14 +1,14 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +use std::collections::HashSet; + use iota_interaction::types::base_types::{IotaAddress, ObjectID}; use serde::{Deserialize, Serialize}; use serde_aux::field_attributes::{deserialize_number_from_string, deserialize_option_number_from_string}; -use std::collections::HashSet; - -use crate::core::utils::deserialize_vec_set; use super::permission::Permission; +use crate::core::utils::deserialize_vec_set; /// Generic wrapper for audit trail events. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Event { @@ -83,13 +83,9 @@ pub struct CapabilityRevoked { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RoleCreated { + #[serde(rename = "target_key")] pub trail_id: ObjectID, pub role: String, - #[serde(deserialize_with = "deserialize_vec_set")] - pub permissions: HashSet, - pub created_by: IotaAddress, - #[serde(deserialize_with = "deserialize_number_from_string")] - pub timestamp: u64, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/audit-trail-rs/src/core/types/permission.rs b/audit-trail-rs/src/core/types/permission.rs index bb9b4a29..07bacfec 100644 --- a/audit-trail-rs/src/core/types/permission.rs +++ b/audit-trail-rs/src/core/types/permission.rs @@ -22,6 +22,28 @@ pub enum Permission { DeleteMetadata, } +impl Permission { + /// Returns the Move constructor function name for this permission variant. + pub(crate) fn move_function_name(&self) -> &'static str { + match self { + Self::DeleteAuditTrail => "delete_audit_trail", + Self::AddRecord => "add_record", + Self::DeleteRecord => "delete_record", + Self::CorrectRecord => "correct_record", + Self::UpdateLockingConfig => "update_locking_config", + Self::UpdateLockingConfigForDeleteRecord => "update_locking_config_for_delete_record", + Self::UpdateLockingConfigForDeleteTrail => "update_locking_config_for_delete_trail", + Self::AddRoles => "add_roles", + Self::UpdateRoles => "update_roles", + Self::DeleteRoles => "delete_roles", + Self::AddCapabilities => "add_capabilities", + Self::RevokeCapabilities => "revoke_capabilities", + Self::UpdateMetadata => "update_metadata", + Self::DeleteMetadata => "delete_metadata", + } + } +} + /// Convenience wrapper for permission sets. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct PermissionSet { diff --git a/audit-trail-rs/src/core/types/record.rs b/audit-trail-rs/src/core/types/record.rs index 043fa23d..0bae99e1 100644 --- a/audit-trail-rs/src/core/types/record.rs +++ b/audit-trail-rs/src/core/types/record.rs @@ -1,6 +1,7 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +use std::collections::{HashMap, HashSet}; use std::str::FromStr; use iota_interaction::types::base_types::IotaAddress; @@ -12,8 +13,6 @@ use serde::{Deserialize, Deserializer, Serialize}; use crate::core::utils; use crate::error::Error; -use std::collections::{HashMap, HashSet}; - /// Page of records loaded through linked-table traversal. #[derive(Debug, Clone)] pub struct PaginatedRecord { diff --git a/audit-trail-rs/src/core/types/role_map.rs b/audit-trail-rs/src/core/types/role_map.rs index b089e7b1..cf8e6f9d 100644 --- a/audit-trail-rs/src/core/types/role_map.rs +++ b/audit-trail-rs/src/core/types/role_map.rs @@ -6,23 +6,23 @@ use std::str::FromStr; use iota_interaction::MoveType; use iota_interaction::types::TypeTag; -use iota_interaction::types::base_types::IotaAddress; -use iota_interaction::types::base_types::ObjectID; +use iota_interaction::types::base_types::{IotaAddress, ObjectID}; use iota_interaction::types::id::UID; use serde::{Deserialize, Serialize}; -use crate::core::utils::deserialize_vec_map; -use crate::core::utils::deserialize_vec_set; - use super::permission::Permission; +use crate::core::utils::{deserialize_vec_map, deserialize_vec_set}; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RoleMap { pub target_key: ObjectID, #[serde(deserialize_with = "deserialize_vec_map")] pub roles: HashMap>, + pub initial_admin_role_name: String, #[serde(deserialize_with = "deserialize_vec_set")] pub issued_capabilities: HashSet, + #[serde(deserialize_with = "deserialize_vec_set")] + pub initial_admin_cap_ids: HashSet, pub role_admin_permissions: RoleAdminPermissions, pub capability_admin_permissions: CapabilityAdminPermissions, } diff --git a/audit-trail-rs/src/core/utils.rs b/audit-trail-rs/src/core/utils.rs index 91111270..80b4bb97 100644 --- a/audit-trail-rs/src/core/utils.rs +++ b/audit-trail-rs/src/core/utils.rs @@ -2,25 +2,26 @@ // SPDX-License-Identifier: Apache-2.0 use std::collections::{HashMap, HashSet}; +use std::fmt::Debug; +use std::hash::Hash; use std::str::FromStr; -use crate::error::Error; use iota_interaction::rpc_types::IotaObjectDataOptions; -use iota_interaction::types::MOVE_STDLIB_PACKAGE_ID; -use iota_interaction::types::base_types::STD_OPTION_MODULE_NAME; -use iota_interaction::types::base_types::{ObjectID, ObjectRef}; +use iota_interaction::types::base_types::{ObjectID, ObjectRef, STD_OPTION_MODULE_NAME}; use iota_interaction::types::collection_types::{VecMap, VecSet}; use iota_interaction::types::object::Owner; -use iota_interaction::types::programmable_transaction_builder::ProgrammableTransactionBuilder as Ptb; -use iota_interaction::types::programmable_transaction_builder::ProgrammableTransactionBuilder; +use iota_interaction::types::programmable_transaction_builder::{ + ProgrammableTransactionBuilder as Ptb, ProgrammableTransactionBuilder, +}; use iota_interaction::types::transaction::{Argument, ObjectArg}; -use iota_interaction::types::{IOTA_CLOCK_OBJECT_ID, IOTA_CLOCK_OBJECT_SHARED_VERSION, TypeTag}; +use iota_interaction::types::{ + IOTA_CLOCK_OBJECT_ID, IOTA_CLOCK_OBJECT_SHARED_VERSION, MOVE_STDLIB_PACKAGE_ID, TypeTag, +}; use iota_interaction::{IotaClientTrait, OptionalSync, ident_str}; use product_common::core_client::CoreClientReadOnly; -use serde::Serialize; -use serde::{Deserialize, Deserializer}; -use std::fmt::Debug; -use std::hash::Hash; +use serde::{Deserialize, Deserializer, Serialize}; + +use crate::error::Error; /// Adds a reference to the on-chain clock to `ptb`'s arguments. pub(crate) fn get_clock_ref(ptb: &mut Ptb) -> Argument { diff --git a/audit-trail-rs/tests/e2e/records.rs b/audit-trail-rs/tests/e2e/records.rs index 39e7ddec..3dc85c83 100644 --- a/audit-trail-rs/tests/e2e/records.rs +++ b/audit-trail-rs/tests/e2e/records.rs @@ -1,9 +1,10 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use crate::client::get_funded_test_client; use audit_trails::core::types::Data; +use crate::client::get_funded_test_client; + #[tokio::test] async fn add_and_fetch_record_roundtrip() -> anyhow::Result<()> { let client = get_funded_test_client().await?; diff --git a/audit-trail-rs/tests/e2e/roles.rs b/audit-trail-rs/tests/e2e/roles.rs index 4f8eb951..e195cc64 100644 --- a/audit-trail-rs/tests/e2e/roles.rs +++ b/audit-trail-rs/tests/e2e/roles.rs @@ -1,11 +1,13 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use crate::client::get_funded_test_client; +use std::collections::HashSet; + use audit_trails::core::types::{CapabilityIssueOptions, Data, Permission, PermissionSet, RoleCreated}; use iota_interaction::types::base_types::{IotaAddress, ObjectID}; use product_common::core_client::CoreClient; -use std::collections::HashSet; + +use crate::client::get_funded_test_client; async fn create_trail(client: &crate::client::TestClient) -> anyhow::Result { let created = client @@ -24,7 +26,6 @@ async fn create_role_with_permissions( role_name: &str, permissions: Vec, ) -> anyhow::Result { - let expected_permissions = permissions.iter().copied().collect::>(); let created = client .trail(trail_id) .roles() @@ -36,8 +37,6 @@ async fn create_role_with_permissions( assert_eq!(created.trail_id, trail_id); assert_eq!(created.role, role_name); - assert_eq!(created.permissions, expected_permissions); - assert!(created.timestamp > 0); Ok(created) } @@ -286,10 +285,7 @@ async fn regular_destroy_rejects_initial_admin_capability() -> anyhow::Result<() let admin_cap_ref = client.get_cap(client.sender_address(), trail_id).await?; let admin_cap_id = ObjectID::from(admin_cap_ref.0); - let result = roles - .destroy_capability(admin_cap_id) - .build_and_execute(&client) - .await; + let result = roles.destroy_capability(admin_cap_id).build_and_execute(&client).await; assert!( result.is_err(), @@ -308,10 +304,7 @@ async fn regular_revoke_rejects_initial_admin_capability() -> anyhow::Result<()> let admin_cap_ref = client.get_cap(client.sender_address(), trail_id).await?; let admin_cap_id = ObjectID::from(admin_cap_ref.0); - let result = roles - .revoke_capability(admin_cap_id) - .build_and_execute(&client) - .await; + let result = roles.revoke_capability(admin_cap_id).build_and_execute(&client).await; assert!( result.is_err(), diff --git a/audit-trail-rs/tests/e2e/trail.rs b/audit-trail-rs/tests/e2e/trail.rs index acc4d89b..8e5cc248 100644 --- a/audit-trail-rs/tests/e2e/trail.rs +++ b/audit-trail-rs/tests/e2e/trail.rs @@ -1,17 +1,18 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use crate::client::get_funded_test_client; use audit_trails::core::types::{ CapabilityIssueOptions, Data, ImmutableMetadata, LockingConfig, Permission, PermissionSet, }; use iota_interaction::types::base_types::{IotaAddress, ObjectID}; use product_common::core_client::CoreClient; +use crate::client::{TestClient, get_funded_test_client}; + /// Creates a trail and issues a MetadataAdmin capability with `UpdateMetadata` /// permission so the owner can call `update_metadata`. async fn create_trail_with_metadata_role( - client: &crate::client::TestClient, + client: &TestClient, initial_record: Data, updatable_metadata: Option<&str>, immutable_metadata: Option, @@ -48,8 +49,6 @@ async fn create_trail_with_metadata_role( Ok(trail_id) } -// ===== Creation ===== - #[tokio::test] async fn create_trail_with_default_builder_settings() -> anyhow::Result<()> { let client = get_funded_test_client().await?; @@ -146,16 +145,13 @@ async fn create_trail_with_custom_admin_address() -> anyhow::Result<()> { println!("Owned objects for custom admin {custom_admin}:"); match cap { - Ok(cap_ref) => println!("Found accredit capability with ID: {}", cap_ref.0), - Err(e) => println!("Error finding accredit capability for custom admin: {e}"), + Ok(cap_ref) => println!("Found admin capability with ID: {}", cap_ref.0), + Err(e) => println!("Error finding admin capability for custom admin: {e}"), } - // assert!(has_admin_capability, "custom admin did not receive admin capability"); Ok(()) } -// ===== Get ===== - #[tokio::test] async fn get_returns_on_chain_trail() -> anyhow::Result<()> { let client = get_funded_test_client().await?; @@ -208,8 +204,6 @@ async fn get_trail_without_metadata() -> anyhow::Result<()> { Ok(()) } -// ===== Update Metadata ===== - #[tokio::test] async fn update_metadata_roundtrip() -> anyhow::Result<()> { let client = get_funded_test_client().await?; From bea9bae12426a3c1ddae0d8e1909d05c03c90f03 Mon Sep 17 00:00:00 2001 From: Yasir Date: Tue, 17 Feb 2026 19:56:05 +0300 Subject: [PATCH 060/189] Refactor role and permission handling in audit trail - Updated `RoleUpdated` struct to remove unused fields. - Enhanced `Permission` enum with new methods for type tagging and programmable transaction building. - Changed `PermissionSet` to use `HashSet` instead of `Vec` for permissions. - Modified `PaginatedRecord` to use `BTreeMap` for ordered records. - Added new test functions for role creation, capability issuance, and record management. - Improved error handling and assertions in tests for better clarity and reliability. - Updated dependencies in `Cargo.toml` for `iota_interaction` and `product_common` to use the latest branch with event emission features. --- Cargo.toml | 27 +- audit-trail-move/Move.lock | 6 +- audit-trail-rs/src/core/records/mod.rs | 120 +++-- audit-trail-rs/src/core/roles/operations.rs | 35 +- audit-trail-rs/src/core/roles/transactions.rs | 18 +- audit-trail-rs/src/core/trail.rs | 21 +- audit-trail-rs/src/core/types/event.rs | 6 +- audit-trail-rs/src/core/types/permission.rs | 57 ++- audit-trail-rs/src/core/types/record.rs | 4 +- audit-trail-rs/tests/e2e/client.rs | 55 ++- audit-trail-rs/tests/e2e/records.rs | 438 +++++++++++++++++- audit-trail-rs/tests/e2e/roles.rs | 147 ++---- audit-trail-rs/tests/e2e/trail.rs | 114 +++-- bindings/wasm/notarization_wasm/Cargo.toml | 23 +- 14 files changed, 746 insertions(+), 325 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index b7b75401..491f0fa7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,20 +17,31 @@ async-trait = "0.1" bcs = "0.1" # Latest hyper is not compatible with axum-server used by iota-sdk. We need to pin it to 1.7 until iota-sdk upgrades axum-server. hyper = "=1.7" # Fix for iota-sdk 1.13 issue with axum-server. -iota-sdk = { git = "https://github.com/iotaledger/iota.git", package = "iota-sdk", tag = "v1.15.0" } -iota_interaction = { git = "https://github.com/iotaledger/product-core.git", tag = "v0.8.10", default-features = false, package = "iota_interaction" } -iota_interaction_rust = { git = "https://github.com/iotaledger/product-core.git", tag = "v0.8.10", default-features = false, package = "iota_interaction_rust" } -iota_interaction_ts = { git = "https://github.com/iotaledger/product-core.git", tag = "v0.8.10", default-features = false, package = "iota_interaction_ts" } -product_common = { git = "https://github.com/iotaledger/product-core.git", tag = "v0.8.10", default-features = false, package = "product_common" } -serde = { version = "1.0", default-features = false, features = ["alloc", "derive"] } +iota-sdk = { git = "https://github.com/iotaledger/iota.git", package = "iota-sdk", tag = "v1.16.2" } +iota_interaction = { git = "https://github.com/iotaledger/product-core.git", branch = "feat/emit-events-for-capabilities", default-features = false, package = "iota_interaction" } +iota_interaction_rust = { git = "https://github.com/iotaledger/product-core.git", branch = "feat/emit-events-for-capabilities", default-features = false, package = "iota_interaction_rust" } +iota_interaction_ts = { git = "https://github.com/iotaledger/product-core.git", branch = "feat/emit-events-for-capabilities", default-features = false, package = "iota_interaction_ts" } +product_common = { git = "https://github.com/iotaledger/product-core.git", branch = "feat/emit-events-for-capabilities", default-features = false, package = "product_common" } +serde = { version = "1.0", default-features = false, features = [ + "alloc", + "derive", +] } serde_json = { version = "1.0", default-features = false } -strum = { version = "0.27", default-features = false, features = ["std", "derive"] } +strum = { version = "0.27", default-features = false, features = [ + "std", + "derive", +] } thiserror = { version = "2.0", default-features = false } serde-aux = { version = "4.7.0", default-features = false } chrono = { version = "0.4", default-features = false } secret-storage = { git = "https://github.com/iotaledger/secret-storage.git", tag = "v0.3.0", default-features = false } sha2 = { version = "0.10", default-features = false } -tokio = { version = "1.46.1", default-features = false, features = ["macros", "sync", "rt", "process"] } +tokio = { version = "1.46.1", default-features = false, features = [ + "macros", + "sync", + "rt", + "process", +] } [profile.release.package.iota_interaction_ts] opt-level = 's' diff --git a/audit-trail-move/Move.lock b/audit-trail-move/Move.lock index 64bed681..c4d67ae4 100644 --- a/audit-trail-move/Move.lock +++ b/audit-trail-move/Move.lock @@ -61,7 +61,7 @@ flavor = "iota" [env] [env.localnet] -chain-id = "b57328de" -original-published-id = "0xfe34e5f97cde357574f7752734dce38cc0237ac14908509280baa92d435c6892" -latest-published-id = "0xfe34e5f97cde357574f7752734dce38cc0237ac14908509280baa92d435c6892" +chain-id = "46a3760f" +original-published-id = "0x9d224d202a4f5010f7d4e100eaa25b7dfc1f1e7147bca9e5cc8162054576bd0b" +latest-published-id = "0x9d224d202a4f5010f7d4e100eaa25b7dfc1f1e7147bca9e5cc8162054576bd0b" published-version = "1" diff --git a/audit-trail-rs/src/core/records/mod.rs b/audit-trail-rs/src/core/records/mod.rs index 4ced4523..80e2e051 100644 --- a/audit-trail-rs/src/core/records/mod.rs +++ b/audit-trail-rs/src/core/records/mod.rs @@ -1,23 +1,22 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use iota_interaction::move_types::annotated_value::MoveValue; -use iota_interaction::rpc_types::IotaMoveValue; +use iota_interaction::rpc_types::{IotaData as _, IotaMoveValue, IotaObjectDataOptions}; use iota_interaction::types::TypeTag; use iota_interaction::types::base_types::ObjectID; use iota_interaction::types::collection_types::{LinkedTable, LinkedTableNode}; -use iota_interaction::types::dynamic_field::DynamicFieldName; +use iota_interaction::types::dynamic_field::{DynamicFieldName, Field}; use iota_interaction::{IotaClientTrait, IotaKeySignature, OptionalSync}; use product_common::core_client::{CoreClient, CoreClientReadOnly}; use product_common::transaction::transaction_builder::TransactionBuilder; use secret_storage::Signer; -use serde::Deserialize; use serde::de::DeserializeOwned; use crate::core::trail::{AuditTrailFull, AuditTrailReadOnly}; -use crate::core::types::{Data, OnChainAuditTrail, PaginatedRecord, Record}; +use crate::core::types::{Data, PaginatedRecord, Record}; use crate::error::Error; mod operations; @@ -27,6 +26,8 @@ pub use transactions::{AddRecord, DeleteRecord}; use self::operations::RecordsOps; +const MAX_LIST_PAGE_LIMIT: usize = 1_000; + #[derive(Debug, Clone)] pub struct TrailRecords<'a, C, D = Data> { pub(crate) client: &'a C, @@ -117,6 +118,12 @@ impl<'a, C, D> TrailRecords<'a, C, D> { C: AuditTrailReadOnly, D: DeserializeOwned, { + if limit > MAX_LIST_PAGE_LIMIT { + return Err(Error::InvalidArgument(format!( + "page limit {limit} exceeds max supported page size {MAX_LIST_PAGE_LIMIT}" + ))); + } + let records_table = self.load_records_table().await?; let (records, next_cursor) = list_linked_table_page::<_, Record>(self.client, &records_table, cursor, limit).await?; @@ -132,35 +139,28 @@ impl<'a, C, D> TrailRecords<'a, C, D> { where C: AuditTrailReadOnly, { - let on_chain_trail: OnChainAuditTrail = self.client.get_object_by_id(self.trail_id).await.map_err(|err| { - Error::UnexpectedApiResponse(format!( - "failed to load on-chain trail {} for hydration; {err}", - self.trail_id - )) - })?; - - Ok(on_chain_trail.records) + crate::core::operations::get_audit_trail(self.trail_id, self.client) + .await + .map(|on_chain_trail| on_chain_trail.records) } } -// ===== Linked-table traversal helpers ===== - async fn list_linked_table_page( client: &C, table: &LinkedTable, start_key: Option, limit: usize, -) -> Result<(HashMap, Option), Error> +) -> Result<(BTreeMap, Option), Error> where C: CoreClientReadOnly + OptionalSync, V: DeserializeOwned, { if limit == 0 { - return Ok((HashMap::new(), start_key.or(table.head))); + return Ok((BTreeMap::new(), start_key.or(table.head))); } let mut cursor = start_key.or(table.head); - let mut items = HashMap::new(); + let mut items = BTreeMap::new(); for _ in 0..limit { let Some(key) = cursor else { break }; @@ -172,38 +172,8 @@ where ))); } - let name = DynamicFieldName { - type_: TypeTag::U64, - value: IotaMoveValue::from(MoveValue::U64(key)).to_json_value(), - }; - - let response = client - .client_adapter() - .read_api() - .get_dynamic_field_object(table.id, name) - .await - .map_err(|err| Error::RpcError(err.to_string()))?; - - let node_object_id = response - .data - .ok_or_else(|| { - Error::UnexpectedApiResponse(format!( - "missing dynamic-field object for linked-table id {} and key {key}", - table.id - )) - })? - .object_id; - - #[derive(Debug, Deserialize)] - struct DynamicFieldObject { - value: LinkedTableNode, - } - - let node: DynamicFieldObject = client.get_object_by_id(node_object_id).await.map_err(|err| { - Error::UnexpectedApiResponse(format!("failed to decode linked-table node {node_object_id}; {err}")) - })?; + let node = fetch_linked_table_node::<_, V>(client, table.id, key).await?; - let node = node.value; cursor = node.next; items.insert(key, node.value); } @@ -244,5 +214,55 @@ where ))); } - Ok(entries) + Ok(entries.into_iter().collect()) +} + +async fn fetch_linked_table_node( + client: &C, + table_id: ObjectID, + key: u64, +) -> Result, Error> +where + C: CoreClientReadOnly + OptionalSync, + V: DeserializeOwned, +{ + let name = DynamicFieldName { + type_: TypeTag::U64, + value: IotaMoveValue::from(MoveValue::U64(key)).to_json_value(), + }; + + let data = client + .client_adapter() + .read_api() + .get_dynamic_field_object_v2(table_id, name, Some(IotaObjectDataOptions::bcs_lossless())) + .await + .map_err(|err| Error::RpcError(err.to_string()))? + .data + .ok_or_else(|| { + Error::UnexpectedApiResponse(format!( + "dynamic-field object not found for linked-table id {table_id} and key {key}" + )) + })?; + + let field: Field> = data + .bcs + .ok_or_else(|| { + Error::UnexpectedApiResponse(format!( + "linked-table node {} missing bcs object content", + data.object_id + )) + })? + .try_into_move() + .ok_or_else(|| { + Error::UnexpectedApiResponse(format!( + "linked-table node {} bcs content is not a move object", + data.object_id + )) + })? + .deserialize() + .map_err(|err| { + Error::UnexpectedApiResponse(format!("failed to decode linked-table node {}; {err}", data.object_id)) + })?; + + Ok(field.value) } diff --git a/audit-trail-rs/src/core/roles/operations.rs b/audit-trail-rs/src/core/roles/operations.rs index d2eade45..c16813a2 100644 --- a/audit-trail-rs/src/core/roles/operations.rs +++ b/audit-trail-rs/src/core/roles/operations.rs @@ -1,11 +1,8 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use std::str::FromStr; - -use iota_interaction::types::TypeTag; use iota_interaction::types::base_types::{IotaAddress, ObjectID}; -use iota_interaction::types::transaction::{Command, ObjectArg, ProgrammableTransaction}; +use iota_interaction::types::transaction::{Argument, Command, ObjectArg, ProgrammableTransaction}; use iota_interaction::{OptionalSync, ident_str}; use product_common::core_client::CoreClientReadOnly; @@ -34,7 +31,7 @@ impl RolesOps { "create_role", |ptb, _| { let role = utils::ptb_pure(ptb, "role", name)?; - let perms_vec = Self::permissions_to_vec(ptb, client.package_id(), permissions.permissions); + let perms_vec = permissions.to_move_vec(client.package_id(), ptb)?; let perms = ptb.programmable_move_call( client.package_id(), ident_str!("permission").into(), @@ -68,7 +65,8 @@ impl RolesOps { "update_role_permissions", |ptb, _| { let role = utils::ptb_pure(ptb, "role", name)?; - let perms_vec = Self::permissions_to_vec(ptb, client.package_id(), permissions.permissions); + let perms_vec = permissions.to_move_vec(client.package_id(), ptb)?; + let perms = ptb.programmable_move_call( client.package_id(), ident_str!("permission").into(), @@ -76,6 +74,7 @@ impl RolesOps { vec![], vec![perms_vec], ); + let clock = utils::get_clock_ref(ptb); Ok(vec![role, perms, clock]) @@ -84,30 +83,6 @@ impl RolesOps { .await } - fn permissions_to_vec( - ptb: &mut iota_interaction::types::programmable_transaction_builder::ProgrammableTransactionBuilder, - package_id: ObjectID, - permissions: Vec, - ) -> iota_interaction::types::transaction::Argument { - let permission_type = TypeTag::from_str(format!("{package_id}::permission::Permission").as_str()) - .expect("invalid TypeTag for Permission"); - let permission_args = permissions - .into_iter() - .map(|permission| Self::permission_to_argument(ptb, package_id, permission)) - .collect::>(); - ptb.command(Command::MakeMoveVec(Some(permission_type.into()), permission_args)) - } - - fn permission_to_argument( - ptb: &mut iota_interaction::types::programmable_transaction_builder::ProgrammableTransactionBuilder, - package_id: ObjectID, - permission: Permission, - ) -> iota_interaction::types::transaction::Argument { - let function = iota_interaction::types::Identifier::from_str(permission.move_function_name()) - .expect("invalid permission function name"); - ptb.programmable_move_call(package_id, ident_str!("permission").into(), function, vec![], vec![]) - } - pub(super) async fn delete_role( client: &C, trail_id: ObjectID, diff --git a/audit-trail-rs/src/core/roles/transactions.rs b/audit-trail-rs/src/core/roles/transactions.rs index f0278870..40eda513 100644 --- a/audit-trail-rs/src/core/roles/transactions.rs +++ b/audit-trail-rs/src/core/roles/transactions.rs @@ -89,14 +89,10 @@ impl Transaction for CreateRole { where C: CoreClientReadOnly + OptionalSync, { - Err(Error::UnexpectedApiResponse( - "RoleCreated output requires transaction events".to_string(), - )) + unreachable!("RoleCreated output requires transaction events") } } -// ===== UpdateRole ===== - #[derive(Debug, Clone)] pub struct UpdateRole { trail_id: ObjectID, @@ -154,13 +150,13 @@ impl Transaction for UpdateRole { where C: CoreClientReadOnly + OptionalSync, { - for data in &events.data { - if let Ok(event) = serde_json::from_value::>(data.parsed_json.clone()) { - return Ok(event.data); - } - } + let event = events + .data + .iter() + .find_map(|data| serde_json::from_value::>(data.parsed_json.clone()).ok()) + .ok_or_else(|| Error::UnexpectedApiResponse("RoleUpdated event not found".to_string()))?; - Err(Error::UnexpectedApiResponse("RoleUpdated event not found".to_string())) + Ok(event.data) } async fn apply(mut self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result diff --git a/audit-trail-rs/src/core/trail.rs b/audit-trail-rs/src/core/trail.rs index 8d5467b3..0e9d73f8 100644 --- a/audit-trail-rs/src/core/trail.rs +++ b/audit-trail-rs/src/core/trail.rs @@ -52,26 +52,7 @@ impl<'a, C> AuditTrailHandle<'a, C> { where C: AuditTrailReadOnly, { - let data = self - .client - .client_adapter() - .read_api() - .get_object_with_options(self.trail_id, IotaObjectDataOptions::bcs_lossless()) - .await - .map_err(|e| Error::UnexpectedApiResponse(format!("failed to fetch trail {} object; {e}", self.trail_id)))? - .data - .ok_or_else(|| Error::UnexpectedApiResponse(format!("trail {} data not found", self.trail_id)))?; - - data.bcs - .ok_or_else(|| Error::UnexpectedApiResponse(format!("trail {} missing bcs object content", self.trail_id)))? - .try_into_move() - .ok_or_else(|| { - Error::UnexpectedApiResponse(format!("trail {} bcs content is not a move object", self.trail_id)) - })? - .deserialize() - .map_err(|e| { - Error::UnexpectedApiResponse(format!("failed to decode trail {} bcs data; {e}", self.trail_id)) - }) + crate::core::operations::get_audit_trail(self.trail_id, self.client).await } /// Updates the trail's updatable metadata. diff --git a/audit-trail-rs/src/core/types/event.rs b/audit-trail-rs/src/core/types/event.rs index 0729fca0..d33651cd 100644 --- a/audit-trail-rs/src/core/types/event.rs +++ b/audit-trail-rs/src/core/types/event.rs @@ -90,13 +90,9 @@ pub struct RoleCreated { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RoleUpdated { + #[serde(rename = "target_key")] pub trail_id: ObjectID, pub role: String, - #[serde(deserialize_with = "deserialize_vec_set")] - pub new_permissions: HashSet, - pub updated_by: IotaAddress, - #[serde(deserialize_with = "deserialize_number_from_string")] - pub timestamp: u64, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/audit-trail-rs/src/core/types/permission.rs b/audit-trail-rs/src/core/types/permission.rs index 07bacfec..530e422b 100644 --- a/audit-trail-rs/src/core/types/permission.rs +++ b/audit-trail-rs/src/core/types/permission.rs @@ -1,7 +1,17 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +use crate::error::Error; +use iota_interaction::ident_str; +use iota_interaction::types::Identifier; +use iota_interaction::types::TypeTag; +use iota_interaction::types::base_types::{IotaAddress, ObjectID}; +use iota_interaction::types::programmable_transaction_builder::ProgrammableTransactionBuilder as Ptb; +use iota_interaction::types::transaction::Argument; +use iota_interaction::types::transaction::{Command, ObjectArg, ProgrammableTransaction}; use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use std::str::FromStr; /// Permission enum matching the Move permission module. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)] @@ -24,7 +34,7 @@ pub enum Permission { impl Permission { /// Returns the Move constructor function name for this permission variant. - pub(crate) fn move_function_name(&self) -> &'static str { + pub(crate) fn function_name(&self) -> &'static str { match self { Self::DeleteAuditTrail => "delete_audit_trail", Self::AddRecord => "add_record", @@ -42,67 +52,84 @@ impl Permission { Self::DeleteMetadata => "delete_metadata", } } + + pub(crate) fn tag(package_id: ObjectID) -> TypeTag { + TypeTag::from_str(&format!("{package_id}::permission::Permission")).expect("invalid TypeTag for Permission") + } + + pub(in crate::core) fn to_ptb(&self, ptb: &mut Ptb, package_id: ObjectID) -> Result { + let function = Identifier::from_str(self.function_name()) + .map_err(|e| Error::InvalidArgument(format!("Failed to create identifier for function: {e}")))?; + + Ok(ptb.programmable_move_call(package_id, ident_str!("permission").into(), function, vec![], vec![])) + } } /// Convenience wrapper for permission sets. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] pub struct PermissionSet { - pub permissions: Vec, + pub permissions: HashSet, } impl PermissionSet { - pub fn empty() -> Self { - Self { permissions: vec![] } - } + pub(crate) fn to_move_vec(&self, package_id: ObjectID, ptb: &mut Ptb) -> Result { + let permission_type = Permission::tag(package_id); + let permission_args: Vec<_> = self + .permissions + .iter() + .map(|permission| permission.to_ptb(ptb, package_id)) + .collect::, _>>()?; + Ok(ptb.command(Command::MakeMoveVec(Some(permission_type.into()), permission_args))) + } pub fn admin_permissions() -> Self { Self { - permissions: vec![ + permissions: HashSet::from([ Permission::DeleteAuditTrail, Permission::AddCapabilities, Permission::RevokeCapabilities, Permission::AddRoles, Permission::UpdateRoles, Permission::DeleteRoles, - ], + ]), } } pub fn record_admin_permissions() -> Self { Self { - permissions: vec![ + permissions: HashSet::from([ Permission::AddRecord, Permission::DeleteRecord, Permission::CorrectRecord, - ], + ]), } } pub fn locking_admin_permissions() -> Self { Self { - permissions: vec![ + permissions: HashSet::from([ Permission::UpdateLockingConfig, Permission::UpdateLockingConfigForDeleteTrail, Permission::UpdateLockingConfigForDeleteRecord, - ], + ]), } } pub fn role_admin_permissions() -> Self { Self { - permissions: vec![Permission::AddRoles, Permission::UpdateRoles, Permission::DeleteRoles], + permissions: HashSet::from([Permission::AddRoles, Permission::UpdateRoles, Permission::DeleteRoles]), } } pub fn cap_admin_permissions() -> Self { Self { - permissions: vec![Permission::AddCapabilities, Permission::RevokeCapabilities], + permissions: HashSet::from_iter(vec![Permission::AddCapabilities, Permission::RevokeCapabilities]), } } pub fn metadata_admin_permissions() -> Self { Self { - permissions: vec![Permission::UpdateMetadata, Permission::DeleteMetadata], + permissions: HashSet::from_iter(vec![Permission::UpdateMetadata, Permission::DeleteMetadata]), } } } diff --git a/audit-trail-rs/src/core/types/record.rs b/audit-trail-rs/src/core/types/record.rs index 0bae99e1..2c6392a8 100644 --- a/audit-trail-rs/src/core/types/record.rs +++ b/audit-trail-rs/src/core/types/record.rs @@ -1,7 +1,7 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeMap, HashSet}; use std::str::FromStr; use iota_interaction::types::base_types::IotaAddress; @@ -16,7 +16,7 @@ use crate::error::Error; /// Page of records loaded through linked-table traversal. #[derive(Debug, Clone)] pub struct PaginatedRecord { - pub records: HashMap>, + pub records: BTreeMap>, pub next_cursor: Option, pub has_next_page: bool, } diff --git a/audit-trail-rs/tests/e2e/client.rs b/audit-trail-rs/tests/e2e/client.rs index b98a5ac4..4a5cb775 100644 --- a/audit-trail-rs/tests/e2e/client.rs +++ b/audit-trail-rs/tests/e2e/client.rs @@ -1,11 +1,14 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +use std::collections::HashSet; use std::ops::Deref; use std::sync::Arc; use audit_trails::AuditTrailClient; -use audit_trails::core::types::Capability; +use audit_trails::core::types::{ + Capability, CapabilityIssueOptions, CapabilityIssued, Data, Permission, PermissionSet, RoleCreated, +}; use iota_interaction::types::base_types::{IotaAddress, ObjectID}; use iota_interaction::types::crypto::PublicKey; use iota_interaction::{IOTA_LOCAL_NETWORK_URL, IotaClientBuilder}; @@ -80,6 +83,56 @@ impl TestClient { .map(|owned_ref| owned_ref.reference.to_object_ref()) .unwrap()) } + + /// Creates a trail with the given initial record data and returns its ObjectID. + pub(crate) async fn create_test_trail(&self, data: Data) -> anyhow::Result { + let created = self + .create_trail() + .with_initial_record(data, None) + .finish() + .build_and_execute(self) + .await? + .output; + Ok(created.trail_id) + } + + /// Creates a role on the given trail with the specified permissions. + pub(crate) async fn create_role( + &self, + trail_id: ObjectID, + role_name: &str, + permissions: impl IntoIterator, + ) -> anyhow::Result { + let created = self + .trail(trail_id) + .roles() + .for_role(role_name) + .create(PermissionSet { + permissions: permissions.into_iter().collect::>(), + }) + .build_and_execute(self) + .await? + .output; + Ok(created) + } + + /// Issues a capability for the given role on the trail. + pub(crate) async fn issue_cap( + &self, + trail_id: ObjectID, + role_name: &str, + options: CapabilityIssueOptions, + ) -> anyhow::Result { + let issued = self + .trail(trail_id) + .roles() + .for_role(role_name) + .issue_capability(options) + .build_and_execute(self) + .await? + .output; + Ok(issued) + } } impl CoreClientReadOnly for TestClient { diff --git a/audit-trail-rs/tests/e2e/records.rs b/audit-trail-rs/tests/e2e/records.rs index 3dc85c83..46f0a169 100644 --- a/audit-trail-rs/tests/e2e/records.rs +++ b/audit-trail-rs/tests/e2e/records.rs @@ -1,41 +1,445 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use audit_trails::core::types::Data; +use audit_trails::core::types::{CapabilityIssueOptions, Data, LockingConfig, Permission}; +use audit_trails::error::Error; +use iota_interaction::types::base_types::ObjectID; +use product_common::core_client::CoreClient; -use crate::client::get_funded_test_client; +use crate::client::{TestClient, get_funded_test_client}; + +async fn grant_role_capability( + client: &TestClient, + trail_id: ObjectID, + role_name: &str, + permissions: impl IntoIterator, +) -> anyhow::Result<()> { + client.create_role(trail_id, role_name, permissions).await?; + client + .issue_cap(trail_id, role_name, CapabilityIssueOptions::default()) + .await?; + Ok(()) +} + +fn assert_text_data(data: Data, expected: &str) { + match data { + Data::Text(actual) => assert_eq!(actual, expected), + other => panic!("expected text data, got {other:?}"), + } +} #[tokio::test] async fn add_and_fetch_record_roundtrip() -> anyhow::Result<()> { let client = get_funded_test_client().await?; - let metadata = Some("audit-trail-e2e".to_string()); + let trail_id = client.create_test_trail(Data::text("records-e2e")).await?; + let records = client.trail(trail_id).records(); + + grant_role_capability(&client, trail_id, "RecordWriter", [Permission::AddRecord]).await?; + + let added = records + .add(Data::text("second record"), Some("second metadata".to_string())) + .build_and_execute(&client) + .await? + .output; + + assert_eq!(added.trail_id, trail_id); + assert_eq!(added.sequence_number, 1); + assert_eq!(added.added_by, client.sender_address()); + assert!(added.timestamp > 0); + + let record = records.get(1).await?; + assert_eq!(record.sequence_number, 1); + assert_eq!(record.metadata, Some("second metadata".to_string())); + assert_eq!(record.added_by, client.sender_address()); + assert!(record.added_at > 0); + assert_text_data(record.data, "second record"); + + assert_eq!(records.record_count().await?, 2); + + Ok(()) +} + +#[tokio::test] +async fn add_record_rejects_mismatched_data_type() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client.create_test_trail(Data::text("text-trail")).await?; + let records = client.trail(trail_id).records(); + + grant_role_capability(&client, trail_id, "RecordWriter", [Permission::AddRecord]).await?; + + let add_mismatch = records + .add(Data::bytes(vec![0xFF, 0x00, 0xAA]), Some("binary payload".to_string())) + .build_and_execute(&client) + .await; + + assert!( + add_mismatch.is_err(), + "adding bytes to a text trail should fail before execution" + ); + assert_eq!(records.record_count().await?, 1); + + Ok(()) +} + +#[tokio::test] +async fn get_missing_record_fails() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client.create_test_trail(Data::text("missing-get")).await?; + let records = client.trail(trail_id).records(); + + let missing = records.get(999).await; + assert!(missing.is_err(), "reading a missing sequence must fail"); + + Ok(()) +} + +#[tokio::test] +async fn delete_record_removes_entry_and_keeps_sequence_monotonic() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client.create_test_trail(Data::text("delete-roundtrip")).await?; + let records = client.trail(trail_id).records(); + grant_role_capability( + &client, + trail_id, + "RecordAdmin", + [Permission::AddRecord, Permission::DeleteRecord], + ) + .await?; + + let added = records + .add(Data::text("surviving record"), Some("keep me".to_string())) + .build_and_execute(&client) + .await? + .output; + assert_eq!(added.sequence_number, 1); + + let deleted = records.delete(0).build_and_execute(&client).await?.output; + assert_eq!(deleted.trail_id, trail_id); + assert_eq!(deleted.sequence_number, 0); + assert_eq!(deleted.deleted_by, client.sender_address()); + assert!(deleted.timestamp > 0); + + assert_eq!(records.record_count().await?, 1); + assert!(records.get(0).await.is_err(), "deleted record should be gone"); + + let remaining = records.get(1).await?; + assert_eq!(remaining.sequence_number, 1); + assert_text_data(remaining.data, "surviving record"); + + let on_chain_trail = client.trail(trail_id).get().await?; + assert_eq!( + on_chain_trail.sequence_number, 2, + "sequence_number should stay monotonic even after deletion" + ); + + Ok(()) +} + +#[tokio::test] +async fn delete_record_requires_delete_permission() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client.create_test_trail(Data::text("delete-perm")).await?; + let records = client.trail(trail_id).records(); + + grant_role_capability(&client, trail_id, "AddOnly", [Permission::AddRecord]).await?; + + let delete_result = records.delete(0).build_and_execute(&client).await; + assert!( + delete_result.is_err(), + "deleting without DeleteRecord permission must fail" + ); + assert!(records.get(0).await.is_ok(), "record should still exist"); + + Ok(()) +} + +#[tokio::test] +async fn delete_record_not_found_fails() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client.create_test_trail(Data::text("delete-not-found")).await?; + let records = client.trail(trail_id).records(); + + grant_role_capability(&client, trail_id, "DeleteOnly", [Permission::DeleteRecord]).await?; + + let delete_missing = records.delete(999).build_and_execute(&client).await; + assert!(delete_missing.is_err(), "deleting a non-existent sequence should fail"); + + Ok(()) +} + +#[tokio::test] +async fn delete_record_fails_while_time_locked() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; let created = client .create_trail() - .with_initial_record(Data::text("audit-trail-e2e"), metadata.clone()) + .with_initial_record(Data::text("locked"), None) + .with_locking_config(LockingConfig::time_based(3600)) .finish() .build_and_execute(&client) .await? .output; + let trail_id = created.trail_id; + let records = client.trail(trail_id).records(); + + grant_role_capability(&client, trail_id, "DeleteOnly", [Permission::DeleteRecord]).await?; + + let delete_locked = records.delete(0).build_and_execute(&client).await; + assert!(delete_locked.is_err(), "time-locked record deletion must fail"); + assert_eq!(records.record_count().await?, 1); + + Ok(()) +} + +#[tokio::test] +async fn sequence_numbers_do_not_reuse_deleted_slots() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client.create_test_trail(Data::text("sequence-stability")).await?; + let records = client.trail(trail_id).records(); + + grant_role_capability( + &client, + trail_id, + "RecordAdmin", + [Permission::AddRecord, Permission::DeleteRecord], + ) + .await?; + + let first_added = records + .add(Data::text("first added"), None) + .build_and_execute(&client) + .await? + .output; + assert_eq!(first_added.sequence_number, 1); + + records.delete(1).build_and_execute(&client).await?; + + let second_added = records + .add(Data::text("second added"), None) + .build_and_execute(&client) + .await? + .output; + assert_eq!( + second_added.sequence_number, 2, + "new records must not reuse deleted sequence slots" + ); + + assert!(records.get(1).await.is_err(), "deleted sequence should remain absent"); + assert_eq!(records.record_count().await?, 2); + assert_text_data(records.get(2).await?.data, "second added"); + Ok(()) +} + +#[tokio::test] +async fn delete_record_fails_while_count_locked() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let created = client + .create_trail() + .with_initial_record(Data::text("count-locked"), None) + .with_locking_config(LockingConfig::count_based(5)) + .finish() + .build_and_execute(&client) + .await? + .output; let trail_id = created.trail_id; + let records = client.trail(trail_id).records(); + + grant_role_capability(&client, trail_id, "DeleteOnly", [Permission::DeleteRecord]).await?; + + let delete_locked = records.delete(0).build_and_execute(&client).await; + assert!(delete_locked.is_err(), "count-locked record deletion must fail"); + assert_eq!(records.record_count().await?, 1); + + Ok(()) +} + +#[tokio::test] +async fn list_and_pagination_support_sparse_sequence_numbers() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client.create_test_trail(Data::text("pagination")).await?; + let records = client.trail(trail_id).records(); + + grant_role_capability( + &client, + trail_id, + "RecordAdmin", + [Permission::AddRecord, Permission::DeleteRecord], + ) + .await?; + + records + .add(Data::text("second"), Some("m2".to_string())) + .build_and_execute(&client) + .await?; + records + .add(Data::text("third"), Some("m3".to_string())) + .build_and_execute(&client) + .await?; + records.delete(1).build_and_execute(&client).await?; + + assert_eq!(records.record_count().await?, 2); + + let listed = records.list().await?; + assert_eq!(listed.len(), 2); + assert!(listed.contains_key(&0)); + assert!(listed.contains_key(&2)); + + let too_small = records.list_with_limit(1).await; + assert!(too_small.is_err(), "limit below table size should fail"); + + let page_1 = records.list_page(None, 1).await?; + assert_eq!(page_1.records.len(), 1); + assert!(page_1.records.contains_key(&0)); + assert!(page_1.has_next_page); + + let page_2 = records.list_page(page_1.next_cursor, 1).await?; + assert_eq!(page_2.records.len(), 1); + assert!(page_2.records.contains_key(&2)); + assert!(!page_2.has_next_page); + assert!(page_2.next_cursor.is_none()); + + Ok(()) +} + +#[tokio::test] +async fn list_and_pagination_multi_page_through_roundtrip() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client.create_test_trail(Data::text("pagination-multi")).await?; + let records = client.trail(trail_id).records(); + + grant_role_capability( + &client, + trail_id, + "RecordAdmin", + [Permission::AddRecord, Permission::DeleteRecord], + ) + .await?; + + for (idx, label) in ["r1", "r2", "r3", "r4", "r5", "r6"].into_iter().enumerate() { + records + .add(Data::text(format!("record-{label}")), Some(format!("meta-{}", idx + 1))) + .build_and_execute(&client) + .await?; + } + + // Create sparse keys: 0,1,3,4,6 + records.delete(2).build_and_execute(&client).await?; + records.delete(5).build_and_execute(&client).await?; + + assert_eq!(records.record_count().await?, 5); + + let list = records.list().await?; + assert_eq!(list.len(), 5); + assert!(list.contains_key(&0)); + assert!(list.contains_key(&1)); + assert!(list.contains_key(&3)); + assert!(list.contains_key(&4)); + assert!(list.contains_key(&6)); + assert_text_data( + list.get(&4).expect("record with key 4 should exist").data.clone(), + "record-r4", + ); + + let limited = records.list_with_limit(5).await?; + assert_eq!(limited.len(), 5); + assert!(records.list_with_limit(4).await.is_err()); - println!("Created trail with ID: {trail_id}"); + // limit=0 returns no records and keeps the traversal cursor at the starting position. + let empty_page = records.list_page(None, 0).await?; + assert!(empty_page.records.is_empty()); + assert!(empty_page.has_next_page); + assert!(empty_page.next_cursor.is_some()); - // let output = client - // .trail(trail_id) - // .records() - // .add(Data::text("audit-trail-e2e"), metadata.clone())? - // .build_and_execute(&client) - // .await?; + let page_1 = records.list_page(None, 2).await?; + assert_eq!(page_1.records.len(), 2); + assert_eq!( + page_1.records.keys().copied().collect::>(), + vec![0, 1], + "page keys should be stable and ordered" + ); + assert!(page_1.records.contains_key(&0)); + assert!(page_1.records.contains_key(&1)); + assert!(page_1.has_next_page); + + let page_2 = records.list_page(page_1.next_cursor, 2).await?; + assert_eq!(page_2.records.len(), 2); + assert_eq!( + page_2.records.keys().copied().collect::>(), + vec![3, 4], + "page keys should be stable and ordered" + ); + assert!(page_2.records.contains_key(&3)); + assert!(page_2.records.contains_key(&4)); + assert!(page_2.has_next_page); + + let page_3 = records.list_page(page_2.next_cursor, 2).await?; + assert_eq!(page_3.records.len(), 1); + assert!(page_3.records.contains_key(&6)); + assert!(!page_3.has_next_page); + assert!(page_3.next_cursor.is_none()); + + Ok(()) +} + +#[tokio::test] +async fn list_page_cursor_validation_and_mid_cursor_start() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client.create_test_trail(Data::text("pagination-cursor")).await?; + let records = client.trail(trail_id).records(); + + grant_role_capability( + &client, + trail_id, + "RecordAdmin", + [Permission::AddRecord, Permission::DeleteRecord], + ) + .await?; + + for label in ["r1", "r2", "r3", "r4"] { + records + .add(Data::text(format!("record-{label}")), None) + .build_and_execute(&client) + .await?; + } + + // Existing keys are now 0,1,2,3,4. + let middle_page = records.list_page(Some(2), 2).await?; + assert_eq!(middle_page.records.len(), 2); + assert_eq!( + middle_page.records.keys().copied().collect::>(), + vec![2, 3], + "page keys should be stable and ordered" + ); + assert!(middle_page.records.contains_key(&2)); + assert!(middle_page.records.contains_key(&3)); + assert!(middle_page.has_next_page); + + // Cursors that do not exist in the linked-table should fail. + let invalid_cursor = records.list_page(Some(999), 1).await; + assert!(invalid_cursor.is_err(), "an invalid cursor should produce an error"); + + Ok(()) +} + +#[tokio::test] +async fn list_page_rejects_limit_above_supported_max() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client.create_test_trail(Data::text("pagination-cap")).await?; + let records = client.trail(trail_id).records(); - // let added = output.output; - // assert_eq!(added.trail_id, trail_id); + let result = records.list_page(None, 1_001).await; - // let record = client.trail(trail_id).records().get(added.sequence_number).await?; - // assert_eq!(record.sequence_number, added.sequence_number); - // assert_eq!(record.metadata, metadata); - // assert_eq!(record.data, Data::text("audit-trail-e2e")); + match result { + Err(Error::InvalidArgument(message)) => { + assert!( + message.contains("exceeds max supported page size"), + "page-size cap error should be explicit: {message}" + ); + } + Err(other) => panic!("expected InvalidArgument for oversized limit, got {other}"), + Ok(page) => panic!("expected oversized limit error, got page: {page:?}"), + } Ok(()) } diff --git a/audit-trail-rs/tests/e2e/roles.rs b/audit-trail-rs/tests/e2e/roles.rs index e195cc64..2f43b3e4 100644 --- a/audit-trail-rs/tests/e2e/roles.rs +++ b/audit-trail-rs/tests/e2e/roles.rs @@ -3,59 +3,25 @@ use std::collections::HashSet; -use audit_trails::core::types::{CapabilityIssueOptions, Data, Permission, PermissionSet, RoleCreated}; +use audit_trails::core::types::{CapabilityIssueOptions, Data, Permission, PermissionSet}; use iota_interaction::types::base_types::{IotaAddress, ObjectID}; use product_common::core_client::CoreClient; use crate::client::get_funded_test_client; -async fn create_trail(client: &crate::client::TestClient) -> anyhow::Result { - let created = client - .create_trail() - .with_initial_record(Data::text("roles-e2e"), None) - .finish() - .build_and_execute(client) - .await? - .output; - Ok(created.trail_id) -} - -async fn create_role_with_permissions( - client: &crate::client::TestClient, - trail_id: ObjectID, - role_name: &str, - permissions: Vec, -) -> anyhow::Result { - let created = client - .trail(trail_id) - .roles() - .for_role(role_name) - .create(PermissionSet { permissions }) - .build_and_execute(client) - .await? - .output; - - assert_eq!(created.trail_id, trail_id); - assert_eq!(created.role, role_name); - - Ok(created) -} - #[tokio::test] async fn create_role_then_issue_capability_default_options() -> anyhow::Result<()> { let client = get_funded_test_client().await?; - let trail_id = create_trail(&client).await?; - let roles = client.trail(trail_id).roles(); + let trail_id = client.create_test_trail(Data::text("roles-e2e")).await?; let role_name = "auditor"; - create_role_with_permissions(&client, trail_id, role_name, vec![Permission::AddRecord]).await?; + client + .create_role(trail_id, role_name, vec![Permission::AddRecord]) + .await?; - let issued = roles - .for_role(role_name) - .issue_capability(CapabilityIssueOptions::default()) - .build_and_execute(&client) - .await? - .output; + let issued = client + .issue_cap(trail_id, role_name, CapabilityIssueOptions::default()) + .await?; assert_eq!(issued.target_key, trail_id); assert_eq!(issued.role, role_name.to_string()); @@ -69,36 +35,28 @@ async fn create_role_then_issue_capability_default_options() -> anyhow::Result<( #[tokio::test] async fn update_role_permissions_then_issue_capability() -> anyhow::Result<()> { let client = get_funded_test_client().await?; - let trail_id = create_trail(&client).await?; + let trail_id = client.create_test_trail(Data::text("roles-e2e")).await?; let roles = client.trail(trail_id).roles(); let role_name = "editor"; - create_role_with_permissions(&client, trail_id, role_name, vec![Permission::AddRecord]).await?; + client + .create_role(trail_id, role_name, vec![Permission::AddRecord]) + .await?; let updated = roles .for_role(role_name) .update_permissions(PermissionSet { - permissions: vec![Permission::AddRecord, Permission::DeleteRecord], + permissions: HashSet::from([Permission::AddRecord, Permission::DeleteRecord]), }) .build_and_execute(&client) .await? .output; assert_eq!(updated.trail_id, trail_id); assert_eq!(updated.role, role_name.to_string()); - assert_eq!( - updated.new_permissions, - [Permission::AddRecord, Permission::DeleteRecord] - .into_iter() - .collect::>() - ); - assert!(updated.timestamp > 0); - let issued = roles - .for_role(role_name) - .issue_capability(CapabilityIssueOptions::default()) - .build_and_execute(&client) - .await? - .output; + let issued = client + .issue_cap(trail_id, role_name, CapabilityIssueOptions::default()) + .await?; assert_eq!(issued.target_key, trail_id); assert_eq!(issued.role, role_name.to_string()); @@ -108,11 +66,13 @@ async fn update_role_permissions_then_issue_capability() -> anyhow::Result<()> { #[tokio::test] async fn delete_role_prevents_new_capability_issuance() -> anyhow::Result<()> { let client = get_funded_test_client().await?; - let trail_id = create_trail(&client).await?; + let trail_id = client.create_test_trail(Data::text("roles-e2e")).await?; let roles = client.trail(trail_id).roles(); let role_name = "to-delete"; - create_role_with_permissions(&client, trail_id, role_name, vec![Permission::AddRecord]).await?; + client + .create_role(trail_id, role_name, vec![Permission::AddRecord]) + .await?; let deleted = roles .for_role(role_name) .delete() @@ -137,11 +97,12 @@ async fn delete_role_prevents_new_capability_issuance() -> anyhow::Result<()> { #[tokio::test] async fn issue_capability_with_constraints() -> anyhow::Result<()> { let client = get_funded_test_client().await?; - let trail_id = create_trail(&client).await?; - let roles = client.trail(trail_id).roles(); + let trail_id = client.create_test_trail(Data::text("roles-e2e")).await?; let role_name = "reviewer"; - create_role_with_permissions(&client, trail_id, role_name, vec![Permission::AddRecord]).await?; + client + .create_role(trail_id, role_name, vec![Permission::AddRecord]) + .await?; let issued_to = IotaAddress::random_for_testing_only(); let constrained = CapabilityIssueOptions { @@ -150,12 +111,7 @@ async fn issue_capability_with_constraints() -> anyhow::Result<()> { valid_until_ms: Some(1_700_000_001_000), }; - let issued = roles - .for_role(role_name) - .issue_capability(constrained.clone()) - .build_and_execute(&client) - .await? - .output; + let issued = client.issue_cap(trail_id, role_name, constrained.clone()).await?; assert_eq!(issued.target_key, trail_id); assert_eq!(issued.role, role_name.to_string()); @@ -169,18 +125,17 @@ async fn issue_capability_with_constraints() -> anyhow::Result<()> { #[tokio::test] async fn revoke_capability_emits_expected_event_data() -> anyhow::Result<()> { let client = get_funded_test_client().await?; - let trail_id = create_trail(&client).await?; + let trail_id = client.create_test_trail(Data::text("roles-e2e")).await?; let roles = client.trail(trail_id).roles(); let role_name = "revoker"; - create_role_with_permissions(&client, trail_id, role_name, vec![Permission::AddRecord]).await?; + client + .create_role(trail_id, role_name, vec![Permission::AddRecord]) + .await?; - let issued = roles - .for_role(role_name) - .issue_capability(CapabilityIssueOptions::default()) - .build_and_execute(&client) - .await? - .output; + let issued = client + .issue_cap(trail_id, role_name, CapabilityIssueOptions::default()) + .await?; let revoked = roles .revoke_capability(issued.capability_id) @@ -196,27 +151,26 @@ async fn revoke_capability_emits_expected_event_data() -> anyhow::Result<()> { #[tokio::test] async fn destroy_capability_emits_expected_event_data() -> anyhow::Result<()> { let client = get_funded_test_client().await?; - let trail_id = create_trail(&client).await?; + let trail_id = client.create_test_trail(Data::text("roles-e2e")).await?; let roles = client.trail(trail_id).roles(); let role_name = "destroyer"; - create_role_with_permissions(&client, trail_id, role_name, vec![Permission::AddRecord]).await?; + client + .create_role(trail_id, role_name, vec![Permission::AddRecord]) + .await?; - let issued_for_destroy = roles - .for_role(role_name) - .issue_capability(CapabilityIssueOptions::default()) - .build_and_execute(&client) - .await? - .output; + let issued = client + .issue_cap(trail_id, role_name, CapabilityIssueOptions::default()) + .await?; let destroyed = roles - .destroy_capability(issued_for_destroy.capability_id) + .destroy_capability(issued.capability_id) .build_and_execute(&client) .await? .output; assert_eq!(destroyed.target_key, trail_id); - assert_eq!(destroyed.capability_id, issued_for_destroy.capability_id); + assert_eq!(destroyed.capability_id, issued.capability_id); assert_eq!(destroyed.role, role_name.to_string()); assert_eq!(destroyed.issued_to, None); assert_eq!(destroyed.valid_from, None); @@ -228,7 +182,7 @@ async fn destroy_capability_emits_expected_event_data() -> anyhow::Result<()> { #[tokio::test] async fn destroy_initial_admin_capability_emits_expected_event() -> anyhow::Result<()> { let client = get_funded_test_client().await?; - let trail_id = create_trail(&client).await?; + let trail_id = client.create_test_trail(Data::text("roles-e2e")).await?; let roles = client.trail(trail_id).roles(); let admin_cap_ref = client.get_cap(client.sender_address(), trail_id).await?; @@ -253,17 +207,14 @@ async fn destroy_initial_admin_capability_emits_expected_event() -> anyhow::Resu #[tokio::test] async fn revoke_initial_admin_capability_emits_expected_event() -> anyhow::Result<()> { let client = get_funded_test_client().await?; - let trail_id = create_trail(&client).await?; - let roles = client.trail(trail_id).roles(); + let trail_id = client.create_test_trail(Data::text("roles-e2e")).await?; // Issue a second admin capability so we can use the original to revoke it - let second_admin = roles - .for_role("Admin") - .issue_capability(CapabilityIssueOptions::default()) - .build_and_execute(&client) - .await? - .output; + let second_admin = client + .issue_cap(trail_id, "Admin", CapabilityIssueOptions::default()) + .await?; + let roles = client.trail(trail_id).roles(); let revoked = roles .revoke_initial_admin_capability(second_admin.capability_id) .build_and_execute(&client) @@ -279,7 +230,7 @@ async fn revoke_initial_admin_capability_emits_expected_event() -> anyhow::Resul #[tokio::test] async fn regular_destroy_rejects_initial_admin_capability() -> anyhow::Result<()> { let client = get_funded_test_client().await?; - let trail_id = create_trail(&client).await?; + let trail_id = client.create_test_trail(Data::text("roles-e2e")).await?; let roles = client.trail(trail_id).roles(); let admin_cap_ref = client.get_cap(client.sender_address(), trail_id).await?; @@ -298,7 +249,7 @@ async fn regular_destroy_rejects_initial_admin_capability() -> anyhow::Result<() #[tokio::test] async fn regular_revoke_rejects_initial_admin_capability() -> anyhow::Result<()> { let client = get_funded_test_client().await?; - let trail_id = create_trail(&client).await?; + let trail_id = client.create_test_trail(Data::text("roles-e2e")).await?; let roles = client.trail(trail_id).roles(); let admin_cap_ref = client.get_cap(client.sender_address(), trail_id).await?; diff --git a/audit-trail-rs/tests/e2e/trail.rs b/audit-trail-rs/tests/e2e/trail.rs index 8e5cc248..b524245f 100644 --- a/audit-trail-rs/tests/e2e/trail.rs +++ b/audit-trail-rs/tests/e2e/trail.rs @@ -1,53 +1,11 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use audit_trails::core::types::{ - CapabilityIssueOptions, Data, ImmutableMetadata, LockingConfig, Permission, PermissionSet, -}; -use iota_interaction::types::base_types::{IotaAddress, ObjectID}; +use audit_trails::core::types::{CapabilityIssueOptions, Data, ImmutableMetadata, LockingConfig, Permission}; +use iota_interaction::types::base_types::IotaAddress; use product_common::core_client::CoreClient; -use crate::client::{TestClient, get_funded_test_client}; - -/// Creates a trail and issues a MetadataAdmin capability with `UpdateMetadata` -/// permission so the owner can call `update_metadata`. -async fn create_trail_with_metadata_role( - client: &TestClient, - initial_record: Data, - updatable_metadata: Option<&str>, - immutable_metadata: Option, -) -> anyhow::Result { - let mut builder = client.create_trail().with_initial_record(initial_record, None); - - if let Some(meta) = updatable_metadata { - builder = builder.with_updatable_metadata(meta); - } - if let Some(imm) = immutable_metadata { - builder = builder.with_trail_metadata(imm); - } - - let created = builder.finish().build_and_execute(client).await?.output; - let trail_id = created.trail_id; - let roles = client.trail(trail_id).roles(); - - // Create a dedicated MetadataAdmin role - roles - .for_role("MetadataAdmin") - .create(PermissionSet { - permissions: vec![Permission::UpdateMetadata], - }) - .build_and_execute(client) - .await?; - - // Issue a capability for it to the current signer - roles - .for_role("MetadataAdmin") - .issue_capability(CapabilityIssueOptions::default()) - .build_and_execute(client) - .await?; - - Ok(trail_id) -} +use crate::client::get_funded_test_client; #[tokio::test] async fn create_trail_with_default_builder_settings() -> anyhow::Result<()> { @@ -207,12 +165,23 @@ async fn get_trail_without_metadata() -> anyhow::Result<()> { #[tokio::test] async fn update_metadata_roundtrip() -> anyhow::Result<()> { let client = get_funded_test_client().await?; - let trail_id = - create_trail_with_metadata_role(&client, Data::text("trail-update-meta-e2e"), Some("before"), None).await?; + + let trail_id = client.create_test_trail(Data::text("trail-update-meta-e2e")).await?; + // Set initial updatable metadata via update_metadata + client + .create_role(trail_id, "MetadataAdmin", vec![Permission::UpdateMetadata]) + .await?; + client + .issue_cap(trail_id, "MetadataAdmin", CapabilityIssueOptions::default()) + .await?; let trail = client.trail(trail_id); - // Verify initial value + trail + .update_metadata(Some("before".to_string())) + .build_and_execute(&client) + .await?; + let before = trail.get().await?; assert_eq!(before.updatable_metadata, Some("before".to_string())); @@ -231,12 +200,22 @@ async fn update_metadata_roundtrip() -> anyhow::Result<()> { #[tokio::test] async fn update_metadata_to_none_clears_value() -> anyhow::Result<()> { let client = get_funded_test_client().await?; - let trail_id = - create_trail_with_metadata_role(&client, Data::text("trail-clear-meta-e2e"), Some("to-be-cleared"), None) - .await?; + + let trail_id = client.create_test_trail(Data::text("trail-clear-meta-e2e")).await?; + client + .create_role(trail_id, "MetadataAdmin", vec![Permission::UpdateMetadata]) + .await?; + client + .issue_cap(trail_id, "MetadataAdmin", CapabilityIssueOptions::default()) + .await?; let trail = client.trail(trail_id); + trail + .update_metadata(Some("to-be-cleared".to_string())) + .build_and_execute(&client) + .await?; + trail.update_metadata(None).build_and_execute(&client).await?; let on_chain = trail.get().await?; @@ -248,7 +227,14 @@ async fn update_metadata_to_none_clears_value() -> anyhow::Result<()> { #[tokio::test] async fn update_metadata_multiple_times() -> anyhow::Result<()> { let client = get_funded_test_client().await?; - let trail_id = create_trail_with_metadata_role(&client, Data::text("trail-multi-meta-e2e"), None, None).await?; + + let trail_id = client.create_test_trail(Data::text("trail-multi-meta-e2e")).await?; + client + .create_role(trail_id, "MetadataAdmin", vec![Permission::UpdateMetadata]) + .await?; + client + .issue_cap(trail_id, "MetadataAdmin", CapabilityIssueOptions::default()) + .await?; let trail = client.trail(trail_id); @@ -276,13 +262,23 @@ async fn update_metadata_does_not_affect_immutable_metadata() -> anyhow::Result< let client = get_funded_test_client().await?; let immutable = ImmutableMetadata::new("Immutable Name".to_string(), Some("frozen".to_string())); - let trail_id = create_trail_with_metadata_role( - &client, - Data::text("trail-immutable-check-e2e"), - Some("mutable"), - Some(immutable.clone()), - ) - .await?; + let created = client + .create_trail() + .with_initial_record(Data::text("trail-immutable-check-e2e"), None) + .with_trail_metadata(immutable.clone()) + .with_updatable_metadata("mutable") + .finish() + .build_and_execute(&client) + .await? + .output; + + let trail_id = created.trail_id; + client + .create_role(trail_id, "MetadataAdmin", vec![Permission::UpdateMetadata]) + .await?; + client + .issue_cap(trail_id, "MetadataAdmin", CapabilityIssueOptions::default()) + .await?; let trail = client.trail(trail_id); diff --git a/bindings/wasm/notarization_wasm/Cargo.toml b/bindings/wasm/notarization_wasm/Cargo.toml index ab4f962e..81380f71 100644 --- a/bindings/wasm/notarization_wasm/Cargo.toml +++ b/bindings/wasm/notarization_wasm/Cargo.toml @@ -21,8 +21,8 @@ async-trait = { version = "0.1", default-features = false } bcs = "0.1.6" console_error_panic_hook = { version = "0.1" } fastcrypto = { git = "https://github.com/MystenLabs/fastcrypto", rev = "69d496c71fb37e3d22fe85e5bbfd4256d61422b9", package = "fastcrypto" } -iota_interaction = { git = "https://github.com/iotaledger/product-core.git", tag = "v0.8.10", package = "iota_interaction", default-features = false } -iota_interaction_ts = { git = "https://github.com/iotaledger/product-core.git", tag = "v0.8.10", package = "iota_interaction_ts" } +iota_interaction = { git = "https://github.com/iotaledger/product-core.git", branch = "feat/emit-events-for-capabilities", package = "iota_interaction", default-features = false } +iota_interaction_ts = { git = "https://github.com/iotaledger/product-core.git", branch = "feat/emit-events-for-capabilities", package = "iota_interaction_ts" } js-sys = { version = "0.3.61" } prefix-hex = { version = "0.7", default-features = false } serde = { version = "1.0", features = ["derive"] } @@ -37,9 +37,16 @@ wasm-bindgen-futures = { version = "0.4", default-features = false } [dependencies.product_common] git = "https://github.com/iotaledger/product-core.git" -tag = "v0.8.10" +branch = "feat/emit-events-for-capabilities" package = "product_common" -features = ["core-client", "transaction", "bindings", "binding-utils", "gas-station", "default-http-client"] +features = [ + "core-client", + "transaction", + "bindings", + "binding-utils", + "gas-station", + "default-http-client", +] [dependencies.notarization] path = "../../../notarization-rs" @@ -47,7 +54,9 @@ default-features = false features = ["gas-station", "default-http-client", "irl"] [target.'cfg(all(target_arch = "wasm32", not(target_os = "wasi")))'.dependencies] -getrandom = { version = "0.3", default-features = false, features = ["wasm_js"] } +getrandom = { version = "0.3", default-features = false, features = [ + "wasm_js", +] } [profile.release] opt-level = 's' @@ -65,4 +74,6 @@ empty_docs = "allow" [lints.rust] # required for current wasm_bindgen version -unexpected_cfgs = { level = "warn", check-cfg = ['cfg(wasm_bindgen_unstable_test_coverage)'] } +unexpected_cfgs = { level = "warn", check-cfg = [ + 'cfg(wasm_bindgen_unstable_test_coverage)', +] } From 4439e800cba0296733bfc29f4c73a8bc71d79599 Mon Sep 17 00:00:00 2001 From: Yasir Date: Wed, 18 Feb 2026 15:46:18 +0300 Subject: [PATCH 061/189] feat: implement migration functionality and refactor related components --- audit-trail-move/Move.lock | 6 +- audit-trail-rs/src/client/full_client.rs | 4 -- audit-trail-rs/src/core/builder.rs | 10 ++-- .../src/core/create/transactions.rs | 31 +++------- audit-trail-rs/src/core/trail.rs | 12 +++- audit-trail-rs/src/core/trail/operations.rs | 22 +++++++ audit-trail-rs/src/core/trail/transactions.rs | 59 ++++++++++++++----- audit-trail-rs/tests/e2e/trail.rs | 22 +++++-- notarization-move/Move.history.json | 8 +-- 9 files changed, 115 insertions(+), 59 deletions(-) diff --git a/audit-trail-move/Move.lock b/audit-trail-move/Move.lock index c4d67ae4..fe3f5d7e 100644 --- a/audit-trail-move/Move.lock +++ b/audit-trail-move/Move.lock @@ -61,7 +61,7 @@ flavor = "iota" [env] [env.localnet] -chain-id = "46a3760f" -original-published-id = "0x9d224d202a4f5010f7d4e100eaa25b7dfc1f1e7147bca9e5cc8162054576bd0b" -latest-published-id = "0x9d224d202a4f5010f7d4e100eaa25b7dfc1f1e7147bca9e5cc8162054576bd0b" +chain-id = "569b64bc" +original-published-id = "0xd651cd91f452ccd1592d749df0828aa15447d42ed9e0dabf90a2c87c59b776fc" +latest-published-id = "0xd651cd91f452ccd1592d749df0828aa15447d42ed9e0dabf90a2c87c59b776fc" published-version = "1" diff --git a/audit-trail-rs/src/client/full_client.rs b/audit-trail-rs/src/client/full_client.rs index b16500b5..e5d5a0ee 100644 --- a/audit-trail-rs/src/client/full_client.rs +++ b/audit-trail-rs/src/client/full_client.rs @@ -149,10 +149,6 @@ impl AuditTrailClient { } } - pub async fn migrate(&self, _trail_id: ObjectID) -> Result<(), Error> { - Err(Error::NotImplemented("AuditTrailClient::migrate")) - } - pub async fn delete_trail(&self, _trail_id: ObjectID) -> Result<(), Error> { Err(Error::NotImplemented("AuditTrailClient::delete_trail")) } diff --git a/audit-trail-rs/src/core/builder.rs b/audit-trail-rs/src/core/builder.rs index fede0bd2..1d6e070a 100644 --- a/audit-trail-rs/src/core/builder.rs +++ b/audit-trail-rs/src/core/builder.rs @@ -13,8 +13,8 @@ use crate::core::create::CreateTrail; #[derive(Debug, Clone, Default)] pub struct AuditTrailBuilder { pub admin: Option, - pub initial_data: Option, - pub initial_record_metadata: Option, + pub record: Option, + pub record_metadata: Option, pub locking_config: LockingConfig, pub trail_metadata: Option, pub updatable_metadata: Option, @@ -22,9 +22,9 @@ pub struct AuditTrailBuilder { impl AuditTrailBuilder { /// Sets the initial record data and optional record metadata. - pub fn with_initial_record(mut self, data: Data, metadata: Option) -> Self { - self.initial_data = Some(data); - self.initial_record_metadata = metadata; + pub fn with_initial_record(mut self, data: impl Into, metadata: Option) -> Self { + self.record = Some(data.into()); + self.record_metadata = metadata; self } diff --git a/audit-trail-rs/src/core/create/transactions.rs b/audit-trail-rs/src/core/create/transactions.rs index a9ffbcca..81f775d5 100644 --- a/audit-trail-rs/src/core/create/transactions.rs +++ b/audit-trail-rs/src/core/create/transactions.rs @@ -15,6 +15,7 @@ use tokio::sync::OnceCell; use super::operations::CreateOps; use crate::core::builder::AuditTrailBuilder; +use crate::core::operations; use crate::core::types::{AuditTrailCreated, Event, OnChainAuditTrail}; use crate::error::Error; @@ -27,29 +28,11 @@ pub struct TrailCreated { } impl TrailCreated { - pub async fn load_on_chain(&self, client: &C) -> Result + pub async fn fetch_audit_trail(&self, client: &C) -> Result where C: CoreClientReadOnly + OptionalSync, { - let data = client - .client_adapter() - .read_api() - .get_object_with_options(self.trail_id, IotaObjectDataOptions::bcs_lossless()) - .await - .map_err(|e| Error::UnexpectedApiResponse(format!("failed to fetch trail {} object; {e}", self.trail_id)))? - .data - .ok_or_else(|| Error::UnexpectedApiResponse(format!("trail {} data not found", self.trail_id)))?; - - data.bcs - .ok_or_else(|| Error::UnexpectedApiResponse(format!("trail {} missing bcs object content", self.trail_id)))? - .try_into_move() - .ok_or_else(|| { - Error::UnexpectedApiResponse(format!("trail {} bcs content is not a move object", self.trail_id)) - })? - .deserialize() - .map_err(|e| { - Error::UnexpectedApiResponse(format!("failed to decode trail {} bcs data; {e}", self.trail_id)) - }) + operations::get_audit_trail(self.trail_id, client).await } } @@ -75,8 +58,8 @@ impl CreateTrail { { let AuditTrailBuilder { admin, - initial_data, - initial_record_metadata, + record: data, + record_metadata, locking_config, trail_metadata, updatable_metadata, @@ -92,8 +75,8 @@ impl CreateTrail { CreateOps::create_trail( client.package_id(), admin, - initial_data, - initial_record_metadata, + data, + record_metadata, locking_config, trail_metadata, updatable_metadata, diff --git a/audit-trail-rs/src/core/trail.rs b/audit-trail-rs/src/core/trail.rs index 0e9d73f8..c6a02c1c 100644 --- a/audit-trail-rs/src/core/trail.rs +++ b/audit-trail-rs/src/core/trail.rs @@ -21,7 +21,7 @@ use crate::error::Error; mod operations; mod transactions; -pub use transactions::UpdateMetadata; +pub use transactions::{Migrate, UpdateMetadata}; /// Marker trait for read-only audit trail clients. #[doc(hidden)] @@ -65,6 +65,16 @@ impl<'a, C> AuditTrailHandle<'a, C> { TransactionBuilder::new(UpdateMetadata::new(self.trail_id, owner, metadata)) } + /// Migrates the trail to the latest package version. + pub fn migrate(&self) -> TransactionBuilder + where + C: AuditTrailFull + CoreClient, + S: Signer + OptionalSync, + { + let owner = self.client.sender_address(); + TransactionBuilder::new(Migrate::new(self.trail_id, owner)) + } + pub fn records(&self) -> TrailRecords<'a, C, Data> { TrailRecords::new(self.client, self.trail_id) } diff --git a/audit-trail-rs/src/core/trail/operations.rs b/audit-trail-rs/src/core/trail/operations.rs index d9738f45..e24ca10a 100644 --- a/audit-trail-rs/src/core/trail/operations.rs +++ b/audit-trail-rs/src/core/trail/operations.rs @@ -13,6 +13,28 @@ use crate::error::Error; pub(super) struct TrailOps; impl TrailOps { + pub(super) async fn migrate( + client: &C, + trail_id: ObjectID, + owner: IotaAddress, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + operations::build_trail_transaction( + client, + trail_id, + owner, + Permission::DeleteAuditTrail, + "migrate", + |ptb, _| { + let clock = utils::get_clock_ref(ptb); + Ok(vec![clock]) + }, + ) + .await + } + pub(super) async fn update_metadata( client: &C, trail_id: ObjectID, diff --git a/audit-trail-rs/src/core/trail/transactions.rs b/audit-trail-rs/src/core/trail/transactions.rs index 435d5165..fb548e0f 100644 --- a/audit-trail-rs/src/core/trail/transactions.rs +++ b/audit-trail-rs/src/core/trail/transactions.rs @@ -3,7 +3,7 @@ use async_trait::async_trait; use iota_interaction::OptionalSync; -use iota_interaction::rpc_types::{IotaTransactionBlockEffects, IotaTransactionBlockEvents}; +use iota_interaction::rpc_types::IotaTransactionBlockEffects; use iota_interaction::types::base_types::{IotaAddress, ObjectID}; use iota_interaction::types::transaction::ProgrammableTransaction; use product_common::core_client::CoreClientReadOnly; @@ -13,7 +13,50 @@ use tokio::sync::OnceCell; use super::operations::TrailOps; use crate::error::Error; -// ===== UpdateMetadata ===== +#[derive(Debug, Clone)] +pub struct Migrate { + trail_id: ObjectID, + owner: IotaAddress, + cached_ptb: OnceCell, +} + +impl Migrate { + pub fn new(trail_id: ObjectID, owner: IotaAddress) -> Self { + Self { + trail_id, + owner, + cached_ptb: OnceCell::new(), + } + } + + async fn make_ptb(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + TrailOps::migrate(client, self.trail_id, self.owner).await + } +} + +#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] +#[cfg_attr(feature = "send-sync", async_trait)] +impl Transaction for Migrate { + type Error = Error; + type Output = (); + + async fn build_programmable_transaction(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() + } + + async fn apply(self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + Ok(()) + } +} #[derive(Debug, Clone)] pub struct UpdateMetadata { @@ -60,16 +103,4 @@ impl Transaction for UpdateMetadata { { Ok(()) } - - async fn apply_with_events( - self, - effects: &mut IotaTransactionBlockEffects, - _events: &mut IotaTransactionBlockEvents, - client: &C, - ) -> Result - where - C: CoreClientReadOnly + OptionalSync, - { - self.apply(effects, client).await - } } diff --git a/audit-trail-rs/tests/e2e/trail.rs b/audit-trail-rs/tests/e2e/trail.rs index b524245f..5835b739 100644 --- a/audit-trail-rs/tests/e2e/trail.rs +++ b/audit-trail-rs/tests/e2e/trail.rs @@ -21,7 +21,7 @@ async fn create_trail_with_default_builder_settings() -> anyhow::Result<()> { assert_eq!(created.creator, client.sender_address()); - let on_chain = created.load_on_chain(&client).await?; + let on_chain = created.fetch_audit_trail(&client).await?; assert_eq!(on_chain.id.object_id(), &created.trail_id); assert_eq!(on_chain.creator, client.sender_address()); assert_eq!(on_chain.sequence_number, 1); @@ -53,7 +53,7 @@ async fn create_trail_with_metadata_and_time_lock() -> anyhow::Result<()> { .await? .output; - let on_chain = created.load_on_chain(&client).await?; + let on_chain = created.fetch_audit_trail(&client).await?; assert_eq!(on_chain.locking_config, LockingConfig::time_based(300)); assert_eq!(on_chain.immutable_metadata, Some(immutable_metadata)); assert_eq!(on_chain.updatable_metadata, Some("updatable metadata".to_string())); @@ -78,7 +78,7 @@ async fn create_trail_with_bytes_and_count_lock() -> anyhow::Result<()> { .await? .output; - let on_chain = created.load_on_chain(&client).await?; + let on_chain = created.fetch_audit_trail(&client).await?; assert_eq!(on_chain.locking_config, LockingConfig::count_based(3)); assert_eq!(on_chain.sequence_number, 1); @@ -101,7 +101,6 @@ async fn create_trail_with_custom_admin_address() -> anyhow::Result<()> { let cap = client.get_cap(custom_admin, created.trail_id).await; - println!("Owned objects for custom admin {custom_admin}:"); match cap { Ok(cap_ref) => println!("Found admin capability with ID: {}", cap_ref.0), Err(e) => println!("Error finding admin capability for custom admin: {e}"), @@ -162,6 +161,21 @@ async fn get_trail_without_metadata() -> anyhow::Result<()> { Ok(()) } +#[tokio::test] +async fn migrate_is_available_on_trail_handle() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client.create_test_trail(Data::text("trail-migrate-e2e")).await?; + + let handle_migrate = client.trail(trail_id).migrate().build_and_execute(&client).await; + + assert!( + handle_migrate.is_err(), + "new trails are already on latest package version, migrate should fail" + ); + + Ok(()) +} + #[tokio::test] async fn update_metadata_roundtrip() -> anyhow::Result<()> { let client = get_funded_test_client().await?; diff --git a/notarization-move/Move.history.json b/notarization-move/Move.history.json index 9da3cf65..526dc361 100644 --- a/notarization-move/Move.history.json +++ b/notarization-move/Move.history.json @@ -1,18 +1,18 @@ { "aliases": { - "testnet": "2304aa97", "mainnet": "6364aad5", + "testnet": "2304aa97", "devnet": "e678123a" }, "envs": { - "6364aad5": [ - "0x909ce9dcd9a5e97b7b8884fac8e018fad9dece348bf73837379b8694ff684cf3" - ], "2304aa97": [ "0x00412bd469b7f980227c6c574090348239852e43aa07818b315854fdd8a2d25f" ], "e678123a": [ "0x0d88bcecde97585d50207a029a85d7ea0bacf73ab741cbaa975a6e279251033a" + ], + "6364aad5": [ + "0x909ce9dcd9a5e97b7b8884fac8e018fad9dece348bf73837379b8694ff684cf3" ] } } \ No newline at end of file From 92f2adcdb3c5f3567f0f8f36653c7caed7777ecb Mon Sep 17 00:00:00 2001 From: Yasir Date: Thu, 19 Feb 2026 14:28:57 +0300 Subject: [PATCH 062/189] feat: implement audit trail migration functionality and refactor locking operations --- audit-trail-move/Move.lock | 8 +- audit-trail-move/sources/audit_trail.move | 13 +- audit-trail-move/sources/locking.move | 115 +++--- audit-trail-move/sources/permission.move | 7 + audit-trail-move/sources/record.move | 60 +++- .../sources/record_correction.move | 64 ---- audit-trail-move/tests/locking_tests.move | 83 ++--- audit-trail-move/tests/record_tests.move | 24 +- audit-trail-rs/src/core/locking/mod.rs | 35 +- audit-trail-rs/src/core/locking/operations.rs | 82 +++++ .../src/core/locking/transactions.rs | 133 +++++++ audit-trail-rs/src/core/trail/operations.rs | 15 +- audit-trail-rs/src/core/types/locking.rs | 96 +++-- audit-trail-rs/src/core/types/permission.rs | 2 + audit-trail-rs/tests/e2e/locking.rs | 338 ++++++++++++++++++ audit-trail-rs/tests/e2e/main.rs | 1 + audit-trail-rs/tests/e2e/records.rs | 12 +- audit-trail-rs/tests/e2e/trail.rs | 33 +- 18 files changed, 828 insertions(+), 293 deletions(-) delete mode 100644 audit-trail-move/sources/record_correction.move create mode 100644 audit-trail-rs/src/core/locking/operations.rs create mode 100644 audit-trail-rs/src/core/locking/transactions.rs create mode 100644 audit-trail-rs/tests/e2e/locking.rs diff --git a/audit-trail-move/Move.lock b/audit-trail-move/Move.lock index fe3f5d7e..35082792 100644 --- a/audit-trail-move/Move.lock +++ b/audit-trail-move/Move.lock @@ -54,14 +54,14 @@ dependencies = [ ] [move.toolchain-version] -compiler-version = "1.16.2" +compiler-version = "1.17.1-rc" edition = "2024.beta" flavor = "iota" [env] [env.localnet] -chain-id = "569b64bc" -original-published-id = "0xd651cd91f452ccd1592d749df0828aa15447d42ed9e0dabf90a2c87c59b776fc" -latest-published-id = "0xd651cd91f452ccd1592d749df0828aa15447d42ed9e0dabf90a2c87c59b776fc" +chain-id = "7e4cb32f" +original-published-id = "0x530901f47ced3c5ce5c25da30bb69122edb9ee7da9bea2efc0f19c778769dacf" +latest-published-id = "0x530901f47ced3c5ce5c25da30bb69122edb9ee7da9bea2efc0f19c778769dacf" published-version = "1" diff --git a/audit-trail-move/sources/audit_trail.move b/audit-trail-move/sources/audit_trail.move index 8425775d..7cc20512 100644 --- a/audit-trail-move/sources/audit_trail.move +++ b/audit-trail-move/sources/audit_trail.move @@ -8,10 +8,9 @@ module audit_trail::main; use audit_trail::{ - locking::{Self, LockingConfig, LockingWindow, set_delete_record_lock}, + locking::{Self, LockingConfig, LockingWindow, set_delete_record}, permission::{Self, Permission}, - record::{Self, Record}, - record_correction + record::{Self, Record, RecordCorrection}, }; use iota::{clock::{Self, Clock}, event, linked_table::{Self, LinkedTable}, vec_set::VecSet}; use std::string::String; @@ -179,7 +178,7 @@ public fun create( 0, creator, timestamp, - record_correction::new(), + record::new_correction(), ); linked_table::push_back(&mut records, 0, record); @@ -256,7 +255,7 @@ entry fun migrate( .roles .is_capability_valid( cap, - &permission::delete_audit_trail(), + &permission::migrate_audit_trail(), clock, ctx, ), @@ -302,7 +301,7 @@ public fun add_record( seq, caller, timestamp, - record_correction::new(), + record::new_correction(), ); linked_table::push_back(&mut trail.records, seq, record); @@ -423,7 +422,7 @@ public fun update_locking_config_for_delete_record( ), EPermissionDenied, ); - set_delete_record_lock(&mut trail.locking_config, new_delete_record_lock); + set_delete_record(&mut trail.locking_config, new_delete_record_lock); } /// Update the trail's mutable metadata diff --git a/audit-trail-move/sources/locking.move b/audit-trail-move/sources/locking.move index 30c8d9cc..11b7036e 100644 --- a/audit-trail-move/sources/locking.move +++ b/audit-trail-move/sources/locking.move @@ -4,106 +4,73 @@ /// Locking configuration for audit trail records module audit_trail::locking; -/// Defines a locking window (time OR count based) -public struct LockingWindow has copy, drop, store { - /// Records locked for N seconds after creation - time_window_seconds: Option, - /// Last N records are always locked - count_window: Option, +/// Defines a locking window (time XOR count based, or none) +public enum LockingWindow has copy, drop, store { + None, + TimeBased { seconds: u64 }, + CountBased { count: u64 }, } /// Top-level locking configuration for the audit trail public struct LockingConfig has copy, drop, store { /// Locking rules for record deletion - delete_record_lock: LockingWindow, + delete_record: LockingWindow, } // ===== LockingWindow Constructors ===== -/// Create a new locking window -/// -/// - `time_window_seconds`: Records are locked for N seconds after creation (None = no time lock) -/// - `count_window`: Last N records are always locked (None = no count lock) -public fun new_window(time_window_seconds: Option, count_window: Option): LockingWindow { - LockingWindow { time_window_seconds, count_window } -} - /// Create a locking window with no restrictions public fun window_none(): LockingWindow { - LockingWindow { - time_window_seconds: option::none(), - count_window: option::none(), - } + LockingWindow::None } /// Create a time-based locking window public fun window_time_based(seconds: u64): LockingWindow { - LockingWindow { - time_window_seconds: option::some(seconds), - count_window: option::none(), - } + LockingWindow::TimeBased { seconds } } /// Create a count-based locking window public fun window_count_based(count: u64): LockingWindow { - LockingWindow { - time_window_seconds: option::none(), - count_window: option::some(count), - } + LockingWindow::CountBased { count } } // ===== LockingConfig Constructors ===== /// Create a new locking configuration -public fun new(delete_record_lock: LockingWindow): LockingConfig { - LockingConfig { delete_record_lock } -} - -/// Create a locking config with no restrictions -public fun none(): LockingConfig { - LockingConfig { - delete_record_lock: window_none(), - } -} - -/// Create a locking config with time-based record deletion lock -public fun time_based(seconds: u64): LockingConfig { - LockingConfig { - delete_record_lock: window_time_based(seconds), - } -} - -/// Create a locking config with count-based record deletion lock -public fun count_based(count: u64): LockingConfig { - LockingConfig { - delete_record_lock: window_count_based(count), - } +public fun new(delete_record: LockingWindow): LockingConfig { + LockingConfig { delete_record } } // ===== LockingWindow Getters ===== /// Get the time window in seconds (if set) -public fun time_window_seconds(window: &LockingWindow): &Option { - &window.time_window_seconds +public fun time_window_seconds(window: &LockingWindow): Option { + match (window) { + LockingWindow::TimeBased { seconds } => option::some(*seconds), + _ => option::none(), + } } /// Get the count window (if set) -public fun count_window(window: &LockingWindow): &Option { - &window.count_window +public fun count_window(window: &LockingWindow): Option { + match (window) { + LockingWindow::CountBased { count } => option::some(*count), + _ => option::none(), + } } // ===== LockingConfig Getters ===== /// Get the record deletion locking window -public fun delete_record_lock(config: &LockingConfig): &LockingWindow { - &config.delete_record_lock +public fun delete_record(config: &LockingConfig): &LockingWindow { + &config.delete_record } // ===== LockingConfig Setters ===== /// Set the record deletion locking window -public(package) fun set_delete_record_lock(config: &mut LockingConfig, window: LockingWindow) { - config.delete_record_lock = window; +public(package) fun set_delete_record(config: &mut LockingConfig, window: LockingWindow) { + config.delete_record = window; } // ===== Locking Logic (LockingWindow) ===== @@ -112,27 +79,27 @@ public(package) fun set_delete_record_lock(config: &mut LockingConfig, window: L /// /// Returns true if the record was created within the time window public fun is_time_locked(window: &LockingWindow, record_timestamp: u64, current_time: u64): bool { - if (window.time_window_seconds.is_none()) { - return false - }; - - let time_window_ms = (*window.time_window_seconds.borrow()) * 1000; - let record_age = current_time - record_timestamp; - record_age < time_window_ms + match (window) { + LockingWindow::TimeBased { seconds } => { + let time_window_ms = (*seconds) * 1000; + let record_age = current_time - record_timestamp; + record_age < time_window_ms + }, + _ => false, + } } /// Check if a record is locked based on count window /// /// Returns true if the record is among the last N records public fun is_count_locked(window: &LockingWindow, sequence_number: u64, total_records: u64): bool { - if (window.count_window.is_none()) { - return false - }; - - let count_window = *window.count_window.borrow(); - - let records_after = total_records - sequence_number - 1; - records_after < count_window + match (window) { + LockingWindow::CountBased { count } => { + let records_after = total_records - sequence_number - 1; + records_after < *count + }, + _ => false, + } } /// Check if a record is locked by a window (either by time or count) @@ -158,7 +125,7 @@ public fun is_locked( current_time: u64, ): bool { is_window_locked( - &config.delete_record_lock, + &config.delete_record, sequence_number, record_timestamp, total_records, diff --git a/audit-trail-move/sources/permission.move b/audit-trail-move/sources/permission.move index 2ba37048..df23c79c 100644 --- a/audit-trail-move/sources/permission.move +++ b/audit-trail-move/sources/permission.move @@ -42,6 +42,8 @@ public enum Permission has copy, drop, store { UpdateMetadata, /// Delete the updatable metadata field DeleteMetadata, + /// Migrate the audit trail to a new version of the contract + Migrate, } /// Create an empty permission set @@ -199,3 +201,8 @@ public fun update_metadata(): Permission { public fun delete_metadata(): Permission { Permission::DeleteMetadata } + +/// Returns a permission allowing to migrate the audit trail to a new version of the contract +public fun migrate_audit_trail(): Permission { + Permission::Migrate +} \ No newline at end of file diff --git a/audit-trail-move/sources/record.move b/audit-trail-move/sources/record.move index 670f98f9..e47c7dbd 100644 --- a/audit-trail-move/sources/record.move +++ b/audit-trail-move/sources/record.move @@ -7,7 +7,7 @@ /// LinkedTable and addressed by trail_id + sequence_number. module audit_trail::record; -use audit_trail::record_correction::RecordCorrection; +use iota::vec_set::{VecSet, Self}; use std::string::String; /// A single record in the audit trail @@ -90,3 +90,61 @@ public(package) fun destroy(record: Record) { correction: _, } = record; } + + +/// Bidirectional correction tracking for audit records +public struct RecordCorrection has copy, drop, store { + replaces: VecSet, + is_replaced_by: Option, +} + +/// Create a new correction tracker for a normal (non-correcting) record +public fun new_correction(): RecordCorrection { + RecordCorrection { + replaces: vec_set::empty(), + is_replaced_by: option::none(), + } +} + +/// Create a correction tracker for a correcting record +public fun with_replaces(replaced_seq_nums: VecSet): RecordCorrection { + RecordCorrection { + replaces: replaced_seq_nums, + is_replaced_by: option::none(), + } +} + +/// Get the set of sequence numbers this record replaces +public fun replaces(correction: &RecordCorrection): &VecSet { + &correction.replaces +} + +/// Get the sequence number of the record that replaced this one +public fun is_replaced_by(correction: &RecordCorrection): Option { + correction.is_replaced_by +} + +/// Check if this record is a correction (replaces other records) +public fun is_correction(correction: &RecordCorrection): bool { + !vec_set::is_empty(&correction.replaces) +} + +/// Check if this record has been replaced by another record +public fun is_replaced(correction: &RecordCorrection): bool { + correction.is_replaced_by.is_some() +} + +/// Set the sequence number of the record that replaced this one +public(package) fun set_replaced_by(correction: &mut RecordCorrection, replacement_seq: u64) { + correction.is_replaced_by = option::some(replacement_seq); +} + +/// Add a sequence number to the set of records this record replaces +public(package) fun add_replaces(correction: &mut RecordCorrection, seq_num: u64) { + correction.replaces.insert(seq_num); +} + +/// Destroy a RecordCorrection +public(package) fun destroy_record_correction(correction: RecordCorrection) { + let RecordCorrection { replaces: _, is_replaced_by: _ } = correction; +} diff --git a/audit-trail-move/sources/record_correction.move b/audit-trail-move/sources/record_correction.move deleted file mode 100644 index 4b2d9015..00000000 --- a/audit-trail-move/sources/record_correction.move +++ /dev/null @@ -1,64 +0,0 @@ -// Copyright (c) 2025 IOTA Stiftung -// SPDX-License-Identifier: Apache-2.0 - -/// Module for tracking correction relationships for a record -module audit_trail::record_correction; - -use iota::vec_set::{Self, VecSet}; - -/// Bidirectional correction tracking for audit records -public struct RecordCorrection has copy, drop, store { - replaces: VecSet, - is_replaced_by: Option, -} - -/// Create a new correction tracker for a normal (non-correcting) record -public fun new(): RecordCorrection { - RecordCorrection { - replaces: vec_set::empty(), - is_replaced_by: option::none(), - } -} - -/// Create a correction tracker for a correcting record -public fun with_replaces(replaced_seq_nums: VecSet): RecordCorrection { - RecordCorrection { - replaces: replaced_seq_nums, - is_replaced_by: option::none(), - } -} - -/// Get the set of sequence numbers this record replaces -public fun replaces(correction: &RecordCorrection): &VecSet { - &correction.replaces -} - -/// Get the sequence number of the record that replaced this one -public fun is_replaced_by(correction: &RecordCorrection): Option { - correction.is_replaced_by -} - -/// Check if this record is a correction (replaces other records) -public fun is_correction(correction: &RecordCorrection): bool { - !vec_set::is_empty(&correction.replaces) -} - -/// Check if this record has been replaced by another record -public fun is_replaced(correction: &RecordCorrection): bool { - correction.is_replaced_by.is_some() -} - -/// Set the sequence number of the record that replaced this one -public(package) fun set_replaced_by(correction: &mut RecordCorrection, replacement_seq: u64) { - correction.is_replaced_by = option::some(replacement_seq); -} - -/// Add a sequence number to the set of records this record replaces -public(package) fun add_replaces(correction: &mut RecordCorrection, seq_num: u64) { - correction.replaces.insert(seq_num); -} - -/// Destroy a RecordCorrection -public(package) fun destroy(correction: RecordCorrection) { - let RecordCorrection { replaces: _, is_replaced_by: _ } = correction; -} diff --git a/audit-trail-move/tests/locking_tests.move b/audit-trail-move/tests/locking_tests.move index c6d84b57..166580b7 100644 --- a/audit-trail-move/tests/locking_tests.move +++ b/audit-trail-move/tests/locking_tests.move @@ -30,7 +30,7 @@ fun test_time_based_locking_within_window() { // Create trail with 1 hour time-based locking { - let locking_config = locking::time_based(3600); + let locking_config = locking::new(locking::window_time_based(3600)); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -70,7 +70,7 @@ fun test_time_based_locking_outside_window() { // Create trail with 1 hour time-based locking { - let locking_config = locking::time_based(3600); + let locking_config = locking::new(locking::window_time_based(3600)); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -108,7 +108,7 @@ fun test_count_based_locking() { // Create trail with count-based locking (last 2 locked) { - let locking_config = locking::count_based(2); + let locking_config = locking::new(locking::window_count_based(2)); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -190,7 +190,7 @@ fun test_count_based_locking_single_record() { // Create trail with "last 3 locked" - single record should be locked { - let locking_config = locking::count_based(3); + let locking_config = locking::new(locking::window_count_based(3)); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -222,7 +222,7 @@ fun test_no_locking() { let mut scenario = ts::begin(admin); { - let locking_config = locking::none(); + let locking_config = locking::new(locking::window_none()); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -256,7 +256,7 @@ fun test_update_locking_config() { // Create trail with no locking { - let locking_config = locking::none(); + let locking_config = locking::new(locking::window_none()); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -306,7 +306,7 @@ fun test_update_locking_config() { // Update to 1 hour time-based locking trail.update_locking_config( &locking_cap, - locking::time_based(3600), + locking::new(locking::window_time_based(3600)), &clock, ts::ctx(&mut scenario), ); @@ -328,7 +328,7 @@ fun test_update_locking_config_permission_denied() { let mut scenario = ts::begin(admin); { - let locking_config = locking::none(); + let locking_config = locking::new(locking::window_none()); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -372,7 +372,7 @@ fun test_update_locking_config_permission_denied() { trail.update_locking_config( &no_locking_cap, - locking::time_based(3600), + locking::new(locking::window_time_based(3600)), &clock, ts::ctx(&mut scenario), ); @@ -390,7 +390,7 @@ fun test_update_locking_config_for_delete_record() { // Create trail with no locking { - let locking_config = locking::none(); + let locking_config = locking::new(locking::window_none()); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -465,7 +465,7 @@ fun test_update_locking_config_for_delete_record_permission_denied() { let mut scenario = ts::begin(admin); { - let locking_config = locking::none(); + let locking_config = locking::new(locking::window_none()); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -528,7 +528,7 @@ fun test_delete_record_after_time_lock_expires() { // Create trail with 1 hour time-based locking and initial record { - let locking_config = locking::time_based(3600); // 1 hour = 3600 seconds + let locking_config = locking::new(locking::window_time_based(3600)); // 1 hour = 3600 seconds let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -615,7 +615,7 @@ fun test_time_lock_boundary_just_before_expiry() { // Create trail with 1 hour time-based locking { - let locking_config = locking::time_based(3600); + let locking_config = locking::new(locking::window_time_based(3600)); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -641,18 +641,16 @@ fun test_time_lock_boundary_just_before_expiry() { ts::end(scenario); } -// ===== Combined Locking Tests ===== +// ===== Variant Locking Tests ===== #[test] -fun test_combined_time_and_count_locking_both_lock() { +fun test_time_based_locking_all_recent_records_locked() { let admin = @0xAD; let mut scenario = ts::begin(admin); - // Create trail with BOTH time-based (1 hour) and count-based (last 2) locking + // Create trail with time-based (1 hour) locking { - let locking_config = locking::new( - locking::new_window(std::option::some(3600), std::option::some(2)), - ); + let locking_config = locking::new(locking::window_time_based(3600)); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -703,14 +701,13 @@ fun test_combined_time_and_count_locking_both_lock() { cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); }; - // Test: Records locked by BOTH time and count + // Test: Records locked by time-based window ts::next_tx(&mut scenario, admin); { let trail = ts::take_shared>(&scenario); let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); - // Shortly after creation - all records time-locked - // Records 3, 4 also count-locked (last 2) + // Shortly after creation - all records are time-locked clock.set_for_testing(initial_time_for_testing() + 2000); // All records should be locked (time lock active for all) @@ -728,15 +725,13 @@ fun test_combined_time_and_count_locking_both_lock() { } #[test] -fun test_combined_locking_time_expired_but_count_locked() { +fun test_count_based_locking_last_records_remain_locked() { let admin = @0xAD; let mut scenario = ts::begin(admin); - // Create trail with time-based (1 hour) and count-based (last 2) locking + // Create trail with count-based (last 2) locking { - let locking_config = locking::new( - locking::new_window(std::option::some(3600), std::option::some(2)), - ); + let locking_config = locking::new(locking::window_count_based(2)); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -787,16 +782,16 @@ fun test_combined_locking_time_expired_but_count_locked() { cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); }; - // Test: Time lock expired, but count lock still active for last 2 records + // Test: Count lock active for last 2 records ts::next_tx(&mut scenario, admin); { let trail = ts::take_shared>(&scenario); let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); - // 2 hours after creation - time lock expired + // 2 hours later, count lock behavior should be unchanged clock.set_for_testing(initial_time_for_testing() + 7200 * 1000); - // Records 0, 1, 2 should be unlocked (time expired, not in last 2) + // Records 0, 1, 2 should be unlocked (not in last 2) assert!(!trail.is_record_locked(0, &clock), 0); assert!(!trail.is_record_locked(1, &clock), 1); assert!(!trail.is_record_locked(2, &clock), 2); @@ -813,15 +808,13 @@ fun test_combined_locking_time_expired_but_count_locked() { } #[test] -fun test_combined_locking_count_satisfied_but_time_locked() { +fun test_time_based_locking_still_locked_before_expiry() { let admin = @0xAD; let mut scenario = ts::begin(admin); - // Create trail with time-based (1 hour) and count-based (last 2) locking + // Create trail with time-based (1 hour) locking { - let locking_config = locking::new( - locking::new_window(std::option::some(3600), std::option::some(2)), - ); + let locking_config = locking::new(locking::window_time_based(3600)); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -872,7 +865,7 @@ fun test_combined_locking_count_satisfied_but_time_locked() { cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); }; - // Test: Count lock satisfied (not in last 2), but time lock still active + // Test: Time lock still active before expiry ts::next_tx(&mut scenario, admin); { let trail = ts::take_shared>(&scenario); @@ -881,8 +874,7 @@ fun test_combined_locking_count_satisfied_but_time_locked() { // Only 30 minutes after creation - time lock still active clock.set_for_testing(initial_time_for_testing() + 1800 * 1000); - // Record 0 is NOT in last 2 (count satisfied), but still time-locked - // Combined locking uses OR logic: locked if EITHER is true + // Records are still locked because time window has not expired yet assert!(trail.is_record_locked(0, &clock), 0); assert!(trail.is_record_locked(1, &clock), 1); assert!(trail.is_record_locked(2, &clock), 2); @@ -895,15 +887,13 @@ fun test_combined_locking_count_satisfied_but_time_locked() { } #[test] -fun test_combined_locking_both_satisfied_can_delete() { +fun test_count_based_locking_old_record_can_delete() { let admin = @0xAD; let mut scenario = ts::begin(admin); - // Create trail with time-based (1 hour) and count-based (last 2) locking + // Create trail with count-based (last 2) locking { - let locking_config = locking::new( - locking::new_window(std::option::some(3600), std::option::some(2)), - ); + let locking_config = locking::new(locking::window_count_based(2)); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -955,7 +945,7 @@ fun test_combined_locking_both_satisfied_can_delete() { cleanup_trail_and_clock(trail, clock); }; - // Test: Both locks satisfied - can delete + // Test: Old record is outside count window and can be deleted ts::next_tx(&mut scenario, admin); { let mut trail = ts::take_shared>(&scenario); @@ -963,11 +953,10 @@ fun test_combined_locking_both_satisfied_can_delete() { let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); - // 2 hours after creation - time lock expired - // Record 0 is not in last 2 - count lock satisfied + // Record 0 is not in last 2 - count lock condition satisfied clock.set_for_testing(initial_time_for_testing() + 7200 * 1000); - // Verify record 0 is unlocked (both conditions satisfied) + // Verify record 0 is unlocked assert!(!trail.is_record_locked(0, &clock), 0); assert!(trail.has_record(0), 1); diff --git a/audit-trail-move/tests/record_tests.move b/audit-trail-move/tests/record_tests.move index d131030f..d5b55e07 100644 --- a/audit-trail-move/tests/record_tests.move +++ b/audit-trail-move/tests/record_tests.move @@ -31,7 +31,7 @@ fun test_add_record_to_empty_trail() { // Setup trail { - let locking_config = locking::none(); + let locking_config = locking::new(locking::window_none()); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -105,7 +105,7 @@ fun test_add_multiple_records() { // Setup trail { - let locking_config = locking::none(); + let locking_config = locking::new(locking::window_none()); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -182,7 +182,7 @@ fun test_add_record_permission_denied() { // Setup trail { - let locking_config = locking::none(); + let locking_config = locking::new(locking::window_none()); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -250,7 +250,7 @@ fun test_delete_record_success() { // Setup trail with initial record { - let locking_config = locking::none(); + let locking_config = locking::new(locking::window_none()); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -319,7 +319,7 @@ fun test_delete_record_permission_denied() { // Setup trail with initial record { - let locking_config = locking::none(); + let locking_config = locking::new(locking::window_none()); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -380,7 +380,7 @@ fun test_delete_record_not_found() { // Setup trail (no initial record) { - let locking_config = locking::none(); + let locking_config = locking::new(locking::window_none()); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -440,7 +440,7 @@ fun test_delete_record_time_locked() { // Setup trail with time-based locking and initial record { - let locking_config = locking::time_based(3600); // 1 hour + let locking_config = locking::new(locking::window_time_based(3600)); // 1 hour let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -501,7 +501,7 @@ fun test_delete_record_count_locked() { // Setup trail with count-based locking and initial record { - let locking_config = locking::count_based(5); // Last 5 records locked + let locking_config = locking::new(locking::window_count_based(5)); // Last 5 records locked let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -562,7 +562,7 @@ fun test_get_record() { // Setup trail with initial record { - let locking_config = locking::none(); + let locking_config = locking::new(locking::window_none()); let initial_data = new_test_data(42, b"Test data"); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, @@ -596,7 +596,7 @@ fun test_get_record_not_found() { // Setup trail (no initial record) { - let locking_config = locking::none(); + let locking_config = locking::new(locking::window_none()); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -625,7 +625,7 @@ fun test_first_last_sequence() { // Setup trail { - let locking_config = locking::none(); + let locking_config = locking::new(locking::window_none()); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -714,7 +714,7 @@ fun test_is_record_locked_not_found() { // Setup trail (no initial record) { - let locking_config = locking::time_based(3600); + let locking_config = locking::new(locking::window_time_based(3600)); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, diff --git a/audit-trail-rs/src/core/locking/mod.rs b/audit-trail-rs/src/core/locking/mod.rs index f3a9bd6b..ae8afa67 100644 --- a/audit-trail-rs/src/core/locking/mod.rs +++ b/audit-trail-rs/src/core/locking/mod.rs @@ -2,11 +2,22 @@ // SPDX-License-Identifier: Apache-2.0 use iota_interaction::types::base_types::ObjectID; +use iota_interaction::{IotaKeySignature, OptionalSync}; +use product_common::core_client::CoreClient; +use product_common::transaction::transaction_builder::TransactionBuilder; +use secret_storage::Signer; use crate::core::trail::{AuditTrailFull, AuditTrailReadOnly}; use crate::core::types::{LockingConfig, LockingWindow}; use crate::error::Error; +mod operations; +mod transactions; + +pub use transactions::{UpdateDeleteRecordWindow, UpdateLockingConfig}; + +use self::operations::LockingOps; + #[derive(Debug, Clone)] pub struct TrailLocking<'a, C> { pub(crate) client: &'a C, @@ -18,24 +29,32 @@ impl<'a, C> TrailLocking<'a, C> { Self { client, trail_id } } - pub async fn update(&self, _config: LockingConfig) -> Result<(), Error> + pub fn update(&self, config: LockingConfig) -> TransactionBuilder where - C: AuditTrailFull, + C: AuditTrailFull + CoreClient, + S: Signer + OptionalSync, { - Err(Error::NotImplemented("TrailLocking::update")) + let owner = self.client.sender_address(); + TransactionBuilder::new(UpdateLockingConfig::new(self.trail_id, owner, config)) } - pub async fn update_delete_record_window(&self, _window: LockingWindow) -> Result<(), Error> + pub fn update_delete_record_window( + &self, + window: LockingWindow, + ) -> TransactionBuilder where - C: AuditTrailFull, + C: AuditTrailFull + CoreClient, + S: Signer + OptionalSync, { - Err(Error::NotImplemented("TrailLocking::update_delete_record_window")) + let owner = self.client.sender_address(); + TransactionBuilder::new(UpdateDeleteRecordWindow::new(self.trail_id, owner, window)) } - pub async fn is_record_locked(&self, _sequence_number: u64) -> Result + pub async fn is_record_locked(&self, sequence_number: u64) -> Result where C: AuditTrailReadOnly, { - Err(Error::NotImplemented("TrailLocking::is_record_locked")) + let tx = LockingOps::is_record_locked(self.client, self.trail_id, sequence_number).await?; + self.client.execute_read_only_transaction(tx).await } } diff --git a/audit-trail-rs/src/core/locking/operations.rs b/audit-trail-rs/src/core/locking/operations.rs new file mode 100644 index 00000000..a33473f6 --- /dev/null +++ b/audit-trail-rs/src/core/locking/operations.rs @@ -0,0 +1,82 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use iota_interaction::OptionalSync; +use iota_interaction::types::base_types::{IotaAddress, ObjectID}; +use iota_interaction::types::transaction::ProgrammableTransaction; +use product_common::core_client::CoreClientReadOnly; + +use crate::core::types::{LockingConfig, LockingWindow, Permission}; +use crate::core::{operations, utils}; +use crate::error::Error; + +pub(super) struct LockingOps; + +impl LockingOps { + pub(super) async fn update_locking_config( + client: &C, + trail_id: ObjectID, + owner: IotaAddress, + new_config: LockingConfig, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + operations::build_trail_transaction( + client, + trail_id, + owner, + Permission::UpdateLockingConfig, + "update_locking_config", + |ptb, _| { + let config = new_config.to_ptb(ptb, client.package_id())?; + let clock = utils::get_clock_ref(ptb); + + Ok(vec![config, clock]) + }, + ) + .await + } + + pub(super) async fn update_delete_record_window( + client: &C, + trail_id: ObjectID, + owner: IotaAddress, + new_window: LockingWindow, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + operations::build_trail_transaction( + client, + trail_id, + owner, + Permission::UpdateLockingConfigForDeleteRecord, + "update_locking_config_for_delete_record", + |ptb, _| { + let window = new_window.to_ptb(ptb, client.package_id())?; + let clock = utils::get_clock_ref(ptb); + + Ok(vec![window, clock]) + }, + ) + .await + } + + pub(super) async fn is_record_locked( + client: &C, + trail_id: ObjectID, + sequence_number: u64, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + operations::build_read_only_transaction(client, trail_id, "is_record_locked", |ptb| { + let sequence_number = utils::ptb_pure(ptb, "sequence_number", sequence_number)?; + let clock = utils::get_clock_ref(ptb); + + Ok(vec![sequence_number, clock]) + }) + .await + } +} diff --git a/audit-trail-rs/src/core/locking/transactions.rs b/audit-trail-rs/src/core/locking/transactions.rs new file mode 100644 index 00000000..ee630b33 --- /dev/null +++ b/audit-trail-rs/src/core/locking/transactions.rs @@ -0,0 +1,133 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use async_trait::async_trait; +use iota_interaction::OptionalSync; +use iota_interaction::rpc_types::{IotaTransactionBlockEffects, IotaTransactionBlockEvents}; +use iota_interaction::types::base_types::{IotaAddress, ObjectID}; +use iota_interaction::types::transaction::ProgrammableTransaction; +use product_common::core_client::CoreClientReadOnly; +use product_common::transaction::transaction_builder::Transaction; +use tokio::sync::OnceCell; + +use super::operations::LockingOps; +use crate::core::types::{LockingConfig, LockingWindow}; +use crate::error::Error; + +#[derive(Debug, Clone)] +pub struct UpdateLockingConfig { + trail_id: ObjectID, + owner: IotaAddress, + config: LockingConfig, + cached_ptb: OnceCell, +} + +impl UpdateLockingConfig { + pub fn new(trail_id: ObjectID, owner: IotaAddress, config: LockingConfig) -> Self { + Self { + trail_id, + owner, + config, + cached_ptb: OnceCell::new(), + } + } + + async fn make_ptb(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + LockingOps::update_locking_config(client, self.trail_id, self.owner, self.config.clone()).await + } +} + +#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] +#[cfg_attr(feature = "send-sync", async_trait)] +impl Transaction for UpdateLockingConfig { + type Error = Error; + type Output = (); + + async fn build_programmable_transaction(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() + } + + async fn apply(self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + Ok(()) + } + + async fn apply_with_events( + self, + effects: &mut IotaTransactionBlockEffects, + _events: &mut IotaTransactionBlockEvents, + client: &C, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + self.apply(effects, client).await + } +} + +#[derive(Debug, Clone)] +pub struct UpdateDeleteRecordWindow { + trail_id: ObjectID, + owner: IotaAddress, + window: LockingWindow, + cached_ptb: OnceCell, +} + +impl UpdateDeleteRecordWindow { + pub fn new(trail_id: ObjectID, owner: IotaAddress, window: LockingWindow) -> Self { + Self { + trail_id, + owner, + window, + cached_ptb: OnceCell::new(), + } + } + + async fn make_ptb(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + LockingOps::update_delete_record_window(client, self.trail_id, self.owner, self.window.clone()).await + } +} + +#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] +#[cfg_attr(feature = "send-sync", async_trait)] +impl Transaction for UpdateDeleteRecordWindow { + type Error = Error; + type Output = (); + + async fn build_programmable_transaction(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() + } + + async fn apply(self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + Ok(()) + } + + async fn apply_with_events( + self, + effects: &mut IotaTransactionBlockEffects, + _events: &mut IotaTransactionBlockEvents, + client: &C, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + self.apply(effects, client).await + } +} diff --git a/audit-trail-rs/src/core/trail/operations.rs b/audit-trail-rs/src/core/trail/operations.rs index e24ca10a..da3f4091 100644 --- a/audit-trail-rs/src/core/trail/operations.rs +++ b/audit-trail-rs/src/core/trail/operations.rs @@ -21,17 +21,10 @@ impl TrailOps { where C: CoreClientReadOnly + OptionalSync, { - operations::build_trail_transaction( - client, - trail_id, - owner, - Permission::DeleteAuditTrail, - "migrate", - |ptb, _| { - let clock = utils::get_clock_ref(ptb); - Ok(vec![clock]) - }, - ) + operations::build_trail_transaction(client, trail_id, owner, Permission::Migrate, "migrate", |ptb, _| { + let clock = utils::get_clock_ref(ptb); + Ok(vec![clock]) + }) .await } diff --git a/audit-trail-rs/src/core/types/locking.rs b/audit-trail-rs/src/core/types/locking.rs index 70a3b076..1daea35d 100644 --- a/audit-trail-rs/src/core/types/locking.rs +++ b/audit-trail-rs/src/core/types/locking.rs @@ -13,33 +13,15 @@ use crate::error::Error; /// Locking configuration for the audit trail. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] pub struct LockingConfig { - pub delete_record_lock: LockingWindow, + pub delete_record: LockingWindow, } impl LockingConfig { - pub fn none() -> Self { - Self { - delete_record_lock: LockingWindow::none(), - } - } - - pub fn time_based(seconds: u64) -> Self { - Self { - delete_record_lock: LockingWindow::time_based(seconds), - } - } - - pub fn count_based(count: u64) -> Self { - Self { - delete_record_lock: LockingWindow::count_based(count), - } - } - /// Creates a new `Argument` from the `LockingConfig`. /// /// To be used when creating or updating locking config on the ledger. pub(in crate::core) fn to_ptb(&self, ptb: &mut Ptb, package_id: ObjectID) -> Result { - let delete_record_lock = self.delete_record_lock.to_ptb(ptb, package_id)?; + let delete_record_lock = self.delete_record.to_ptb(ptb, package_id)?; Ok(ptb.programmable_move_call( package_id, @@ -51,48 +33,52 @@ impl LockingConfig { } } -/// Defines a locking window (time or count based). +/// Defines a locking window (none, time based, or count based). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] -pub struct LockingWindow { - pub time_window_seconds: Option, - pub count_window: Option, +pub enum LockingWindow { + #[default] + None, + TimeBased { + seconds: u64, + }, + CountBased { + count: u64, + }, } impl LockingWindow { - pub fn none() -> Self { - Self { - time_window_seconds: None, - count_window: None, - } - } - - pub fn time_based(seconds: u64) -> Self { - Self { - time_window_seconds: Some(seconds), - count_window: None, - } - } - - pub fn count_based(count: u64) -> Self { - Self { - time_window_seconds: None, - count_window: Some(count), - } - } - /// Creates a new `Argument` from the `LockingWindow`. /// /// To be used when creating or updating locking config on the ledger. pub(in crate::core) fn to_ptb(&self, ptb: &mut Ptb, package_id: ObjectID) -> Result { - let time_window_seconds = utils::ptb_pure(ptb, "time_window_seconds", self.time_window_seconds)?; - let count_window = utils::ptb_pure(ptb, "count_window", self.count_window)?; - - Ok(ptb.programmable_move_call( - package_id, - ident_str!("locking").into(), - ident_str!("new_window").into(), - vec![], - vec![time_window_seconds, count_window], - )) + match self { + Self::None => Ok(ptb.programmable_move_call( + package_id, + ident_str!("locking").into(), + ident_str!("window_none").into(), + vec![], + vec![], + )), + Self::TimeBased { seconds } => { + let seconds = utils::ptb_pure(ptb, "seconds", *seconds)?; + Ok(ptb.programmable_move_call( + package_id, + ident_str!("locking").into(), + ident_str!("window_time_based").into(), + vec![], + vec![seconds], + )) + } + Self::CountBased { count } => { + let count = utils::ptb_pure(ptb, "count", *count)?; + Ok(ptb.programmable_move_call( + package_id, + ident_str!("locking").into(), + ident_str!("window_count_based").into(), + vec![], + vec![count], + )) + } + } } } diff --git a/audit-trail-rs/src/core/types/permission.rs b/audit-trail-rs/src/core/types/permission.rs index 530e422b..d28460cd 100644 --- a/audit-trail-rs/src/core/types/permission.rs +++ b/audit-trail-rs/src/core/types/permission.rs @@ -30,6 +30,7 @@ pub enum Permission { RevokeCapabilities, UpdateMetadata, DeleteMetadata, + Migrate, } impl Permission { @@ -50,6 +51,7 @@ impl Permission { Self::RevokeCapabilities => "revoke_capabilities", Self::UpdateMetadata => "update_metadata", Self::DeleteMetadata => "delete_metadata", + Self::Migrate => "migrate_audit_trail", } } diff --git a/audit-trail-rs/tests/e2e/locking.rs b/audit-trail-rs/tests/e2e/locking.rs new file mode 100644 index 00000000..005885c9 --- /dev/null +++ b/audit-trail-rs/tests/e2e/locking.rs @@ -0,0 +1,338 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use audit_trails::core::types::{CapabilityIssueOptions, Data, LockingConfig, LockingWindow, Permission}; +use iota_interaction::types::base_types::ObjectID; + +use crate::client::{get_funded_test_client, TestClient}; + +async fn grant_role_capability( + client: &TestClient, + trail_id: ObjectID, + role_name: &str, + permissions: impl IntoIterator, +) -> anyhow::Result<()> { + client.create_role(trail_id, role_name, permissions).await?; + client + .issue_cap(trail_id, role_name, CapabilityIssueOptions::default()) + .await?; + Ok(()) +} + +#[tokio::test] +async fn update_locking_config_roundtrip() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client.create_test_trail(Data::text("trail-update-locking-e2e")).await?; + let trail = client.trail(trail_id); + + grant_role_capability(&client, trail_id, "LockingAdmin", [Permission::UpdateLockingConfig]).await?; + + trail + .locking() + .update(LockingConfig { + delete_record: LockingWindow::CountBased { count: 2 }, + }) + .build_and_execute(&client) + .await?; + + let on_chain = trail.get().await?; + assert_eq!( + on_chain.locking_config, + LockingConfig { + delete_record: LockingWindow::CountBased { count: 2 } + } + ); + + Ok(()) +} + +#[tokio::test] +async fn update_locking_config_switches_count_to_time_based() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client + .create_trail() + .with_initial_record(Data::text("trail-switch-count-to-time-e2e"), None) + .with_locking_config(LockingConfig { + delete_record: LockingWindow::CountBased { count: 3 }, + }) + .finish() + .build_and_execute(&client) + .await? + .output + .trail_id; + let trail = client.trail(trail_id); + + grant_role_capability(&client, trail_id, "LockingAdmin", [Permission::UpdateLockingConfig]).await?; + + let before = trail.get().await?; + assert_eq!( + before.locking_config, + LockingConfig { + delete_record: LockingWindow::CountBased { count: 3 } + } + ); + + trail + .locking() + .update(LockingConfig { + delete_record: LockingWindow::TimeBased { seconds: 300 }, + }) + .build_and_execute(&client) + .await?; + + let after = trail.get().await?; + assert_eq!( + after.locking_config, + LockingConfig { + delete_record: LockingWindow::TimeBased { seconds: 300 } + } + ); + + Ok(()) +} + +#[tokio::test] +async fn update_delete_record_window_roundtrip() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client + .create_test_trail(Data::text("trail-update-delete-window-e2e")) + .await?; + let trail = client.trail(trail_id); + + grant_role_capability( + &client, + trail_id, + "DeleteWindowAdmin", + [Permission::UpdateLockingConfigForDeleteRecord], + ) + .await?; + + trail + .locking() + .update_delete_record_window(LockingWindow::TimeBased { seconds: 120 }) + .build_and_execute(&client) + .await?; + + let on_chain = trail.get().await?; + assert_eq!( + on_chain.locking_config, + LockingConfig { + delete_record: LockingWindow::TimeBased { seconds: 120 } + } + ); + + Ok(()) +} + +#[tokio::test] +async fn update_locking_config_requires_permission() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client + .create_test_trail(Data::text("trail-locking-permission-e2e")) + .await?; + + let result = client + .trail(trail_id) + .locking() + .update(LockingConfig { + delete_record: LockingWindow::TimeBased { seconds: 60 }, + }) + .build_and_execute(&client) + .await; + + assert!( + result.is_err(), + "updating locking config without UpdateLockingConfig permission must fail" + ); + + Ok(()) +} + +#[tokio::test] +async fn is_record_locked_supports_count_window_and_missing_sequence() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client + .create_trail() + .with_initial_record(Data::text("trail-locking-status-e2e"), None) + .with_locking_config(LockingConfig { + delete_record: LockingWindow::CountBased { count: 2 }, + }) + .finish() + .build_and_execute(&client) + .await? + .output + .trail_id; + let trail = client.trail(trail_id); + + grant_role_capability(&client, trail_id, "RecordWriter", [Permission::AddRecord]).await?; + + trail + .records() + .add(Data::text("record-1"), None) + .build_and_execute(&client) + .await?; + trail + .records() + .add(Data::text("record-2"), None) + .build_and_execute(&client) + .await?; + + assert!( + !trail.locking().is_record_locked(0).await?, + "oldest record should be unlocked with count window of 2 and total records of 3" + ); + assert!( + trail.locking().is_record_locked(2).await?, + "latest record should be locked with count window of 2" + ); + + let missing = trail.locking().is_record_locked(999).await; + assert!(missing.is_err(), "missing sequence should fail"); + + Ok(()) +} + +#[tokio::test] +async fn delete_window_variants_roundtrip() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client + .create_test_trail(Data::text("trail-locking-window-variants-e2e")) + .await?; + let trail = client.trail(trail_id); + + grant_role_capability( + &client, + trail_id, + "DeleteWindowAdmin", + [Permission::UpdateLockingConfigForDeleteRecord], + ) + .await?; + + trail + .locking() + .update_delete_record_window(LockingWindow::TimeBased { seconds: 3600 }) + .build_and_execute(&client) + .await?; + + let on_chain = trail.get().await?; + assert_eq!( + on_chain.locking_config, + LockingConfig { + delete_record: LockingWindow::TimeBased { seconds: 3600 } + } + ); + + trail + .locking() + .update_delete_record_window(LockingWindow::CountBased { count: 1 }) + .build_and_execute(&client) + .await?; + + let on_chain = trail.get().await?; + assert_eq!( + on_chain.locking_config, + LockingConfig { + delete_record: LockingWindow::CountBased { count: 1 } + } + ); + + trail + .locking() + .update_delete_record_window(LockingWindow::None) + .build_and_execute(&client) + .await?; + + let on_chain = trail.get().await?; + assert_eq!( + on_chain.locking_config, + LockingConfig { + delete_record: LockingWindow::None + } + ); + + Ok(()) +} + +#[tokio::test] +async fn updated_time_lock_blocks_record_deletion() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client + .create_test_trail(Data::text("trail-locking-delete-time-e2e")) + .await?; + let trail = client.trail(trail_id); + + grant_role_capability( + &client, + trail_id, + "LockAndDeleteAdmin", + [ + Permission::AddRecord, + Permission::DeleteRecord, + Permission::UpdateLockingConfig, + ], + ) + .await?; + + trail + .records() + .add("deletable-before-lock".into(), None) + .build_and_execute(&client) + .await?; + + trail + .locking() + .update(LockingConfig { + delete_record: LockingWindow::TimeBased { seconds: 3600 }, + }) + .build_and_execute(&client) + .await?; + + let delete_locked = trail.records().delete(1).build_and_execute(&client).await; + assert!( + delete_locked.is_err(), + "deleting a record should fail after enabling a time-based delete lock" + ); + assert_eq!(trail.records().record_count().await?, 2); + + Ok(()) +} + +#[tokio::test] +async fn updated_delete_window_can_block_and_then_allow_deletion() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client + .create_test_trail(Data::text("trail-locking-delete-window-e2e")) + .await?; + let trail = client.trail(trail_id); + + grant_role_capability( + &client, + trail_id, + "DeleteWindowAdmin", + [Permission::DeleteRecord, Permission::UpdateLockingConfigForDeleteRecord], + ) + .await?; + + trail + .locking() + .update_delete_record_window(LockingWindow::CountBased { count: 1 }) + .build_and_execute(&client) + .await?; + + let delete_locked = trail.records().delete(0).build_and_execute(&client).await; + assert!( + delete_locked.is_err(), + "count-based window should block deleting the latest record" + ); + + trail + .locking() + .update_delete_record_window(LockingWindow::None) + .build_and_execute(&client) + .await?; + + trail.records().delete(0).build_and_execute(&client).await?; + assert_eq!(trail.records().record_count().await?, 0); + + Ok(()) +} diff --git a/audit-trail-rs/tests/e2e/main.rs b/audit-trail-rs/tests/e2e/main.rs index 04b65814..b22967de 100644 --- a/audit-trail-rs/tests/e2e/main.rs +++ b/audit-trail-rs/tests/e2e/main.rs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 mod client; +mod locking; mod records; mod roles; mod trail; diff --git a/audit-trail-rs/tests/e2e/records.rs b/audit-trail-rs/tests/e2e/records.rs index 46f0a169..c8b134ec 100644 --- a/audit-trail-rs/tests/e2e/records.rs +++ b/audit-trail-rs/tests/e2e/records.rs @@ -1,12 +1,12 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use audit_trails::core::types::{CapabilityIssueOptions, Data, LockingConfig, Permission}; +use audit_trails::core::types::{CapabilityIssueOptions, Data, LockingConfig, LockingWindow, Permission}; use audit_trails::error::Error; use iota_interaction::types::base_types::ObjectID; use product_common::core_client::CoreClient; -use crate::client::{TestClient, get_funded_test_client}; +use crate::client::{get_funded_test_client, TestClient}; async fn grant_role_capability( client: &TestClient, @@ -174,7 +174,9 @@ async fn delete_record_fails_while_time_locked() -> anyhow::Result<()> { let created = client .create_trail() .with_initial_record(Data::text("locked"), None) - .with_locking_config(LockingConfig::time_based(3600)) + .with_locking_config(LockingConfig { + delete_record: LockingWindow::TimeBased { seconds: 3600 }, + }) .finish() .build_and_execute(&client) .await? @@ -237,7 +239,9 @@ async fn delete_record_fails_while_count_locked() -> anyhow::Result<()> { let created = client .create_trail() .with_initial_record(Data::text("count-locked"), None) - .with_locking_config(LockingConfig::count_based(5)) + .with_locking_config(LockingConfig { + delete_record: LockingWindow::CountBased { count: 5 }, + }) .finish() .build_and_execute(&client) .await? diff --git a/audit-trail-rs/tests/e2e/trail.rs b/audit-trail-rs/tests/e2e/trail.rs index 5835b739..d9c3f16d 100644 --- a/audit-trail-rs/tests/e2e/trail.rs +++ b/audit-trail-rs/tests/e2e/trail.rs @@ -1,7 +1,9 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use audit_trails::core::types::{CapabilityIssueOptions, Data, ImmutableMetadata, LockingConfig, Permission}; +use audit_trails::core::types::{ + CapabilityIssueOptions, Data, ImmutableMetadata, LockingConfig, LockingWindow, Permission, +}; use iota_interaction::types::base_types::IotaAddress; use product_common::core_client::CoreClient; @@ -25,7 +27,12 @@ async fn create_trail_with_default_builder_settings() -> anyhow::Result<()> { assert_eq!(on_chain.id.object_id(), &created.trail_id); assert_eq!(on_chain.creator, client.sender_address()); assert_eq!(on_chain.sequence_number, 1); - assert_eq!(on_chain.locking_config, LockingConfig::none()); + assert_eq!( + on_chain.locking_config, + LockingConfig { + delete_record: LockingWindow::None + } + ); assert!(on_chain.immutable_metadata.is_none()); assert!(on_chain.updatable_metadata.is_none()); @@ -45,7 +52,9 @@ async fn create_trail_with_metadata_and_time_lock() -> anyhow::Result<()> { Data::text("audit-trail-create-time-lock"), Some("initial record metadata".to_string()), ) - .with_locking_config(LockingConfig::time_based(300)) + .with_locking_config(LockingConfig { + delete_record: LockingWindow::TimeBased { seconds: 300 }, + }) .with_trail_metadata(immutable_metadata.clone()) .with_updatable_metadata("updatable metadata") .finish() @@ -54,7 +63,12 @@ async fn create_trail_with_metadata_and_time_lock() -> anyhow::Result<()> { .output; let on_chain = created.fetch_audit_trail(&client).await?; - assert_eq!(on_chain.locking_config, LockingConfig::time_based(300)); + assert_eq!( + on_chain.locking_config, + LockingConfig { + delete_record: LockingWindow::TimeBased { seconds: 300 } + } + ); assert_eq!(on_chain.immutable_metadata, Some(immutable_metadata)); assert_eq!(on_chain.updatable_metadata, Some("updatable metadata".to_string())); @@ -71,7 +85,9 @@ async fn create_trail_with_bytes_and_count_lock() -> anyhow::Result<()> { Data::bytes(vec![0xAA, 0xBB, 0xCC, 0xDD]), Some("bytes metadata".to_string()), ) - .with_locking_config(LockingConfig::count_based(3)) + .with_locking_config(LockingConfig { + delete_record: LockingWindow::CountBased { count: 3 }, + }) .with_trail_metadata_parts("Trail Count Lock", Some("count lock description".to_string())) .finish() .build_and_execute(&client) @@ -79,7 +95,12 @@ async fn create_trail_with_bytes_and_count_lock() -> anyhow::Result<()> { .output; let on_chain = created.fetch_audit_trail(&client).await?; - assert_eq!(on_chain.locking_config, LockingConfig::count_based(3)); + assert_eq!( + on_chain.locking_config, + LockingConfig { + delete_record: LockingWindow::CountBased { count: 3 } + } + ); assert_eq!(on_chain.sequence_number, 1); Ok(()) From dcb7d7fb253a1cb30b9501d1180b1372fd41a09f Mon Sep 17 00:00:00 2001 From: Yasir Date: Thu, 19 Feb 2026 18:32:19 +0300 Subject: [PATCH 063/189] feat: add batch deletion of records and permission management - Introduced `DeleteAllRecords` permission to manage batch deletions. - Implemented `delete_records_batch` functionality in the records module to allow deletion of multiple records at once. - Enhanced permission checks to ensure that only authorized roles can perform batch deletions. - Added tests to validate the new batch deletion feature and its integration with existing audit trail functionalities. - Updated the audit trail deletion process to ensure it fails when records exist, enforcing data integrity. - Refactored locking mechanisms to accommodate new deletion workflows. --- Cargo.toml | 27 +- audit-trail-move/Move.lock | 4 +- audit-trail-move/sources/audit_trail.move | 228 +++++++++++------ audit-trail-move/sources/permission.move | 12 +- audit-trail-move/sources/record.move | 3 +- audit-trail-move/tests/locking_tests.move | 239 +++++++++++++++++- audit-trail-rs/Cargo.toml | 2 +- audit-trail-rs/src/core/locking/mod.rs | 5 +- .../src/core/locking/transactions.rs | 24 -- audit-trail-rs/src/core/records/mod.rs | 11 +- audit-trail-rs/src/core/records/operations.rs | 24 ++ .../src/core/records/transactions.rs | 93 ++++++- audit-trail-rs/src/core/roles/transactions.rs | 106 +++----- audit-trail-rs/src/core/trail.rs | 14 +- audit-trail-rs/src/core/trail/operations.rs | 22 ++ audit-trail-rs/src/core/trail/transactions.rs | 66 ++++- audit-trail-rs/src/core/types/permission.rs | 17 +- audit-trail-rs/tests/e2e/locking.rs | 2 +- audit-trail-rs/tests/e2e/records.rs | 66 ++++- audit-trail-rs/tests/e2e/trail.rs | 75 ++++++ bindings/wasm/notarization_wasm/Cargo.toml | 20 +- 21 files changed, 814 insertions(+), 246 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 491f0fa7..aebffd63 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,13 +8,14 @@ rust-version = "1.85" [workspace] resolver = "2" -members = ["examples", "notarization-rs", "audit-trail-rs"] +members = ["audit-trail-rs", "examples", "notarization-rs"] exclude = ["bindings/wasm/notarization_wasm"] [workspace.dependencies] anyhow = "1.0" async-trait = "0.1" bcs = "0.1" +chrono = { version = "0.4", default-features = false } # Latest hyper is not compatible with axum-server used by iota-sdk. We need to pin it to 1.7 until iota-sdk upgrades axum-server. hyper = "=1.7" # Fix for iota-sdk 1.13 issue with axum-server. iota-sdk = { git = "https://github.com/iotaledger/iota.git", package = "iota-sdk", tag = "v1.16.2" } @@ -22,26 +23,14 @@ iota_interaction = { git = "https://github.com/iotaledger/product-core.git", bra iota_interaction_rust = { git = "https://github.com/iotaledger/product-core.git", branch = "feat/emit-events-for-capabilities", default-features = false, package = "iota_interaction_rust" } iota_interaction_ts = { git = "https://github.com/iotaledger/product-core.git", branch = "feat/emit-events-for-capabilities", default-features = false, package = "iota_interaction_ts" } product_common = { git = "https://github.com/iotaledger/product-core.git", branch = "feat/emit-events-for-capabilities", default-features = false, package = "product_common" } -serde = { version = "1.0", default-features = false, features = [ - "alloc", - "derive", -] } -serde_json = { version = "1.0", default-features = false } -strum = { version = "0.27", default-features = false, features = [ - "std", - "derive", -] } -thiserror = { version = "2.0", default-features = false } -serde-aux = { version = "4.7.0", default-features = false } -chrono = { version = "0.4", default-features = false } secret-storage = { git = "https://github.com/iotaledger/secret-storage.git", tag = "v0.3.0", default-features = false } +serde = { version = "1.0", default-features = false, features = ["alloc", "derive"] } +serde-aux = { version = "4.7.0", default-features = false } +serde_json = { version = "1.0", default-features = false } sha2 = { version = "0.10", default-features = false } -tokio = { version = "1.46.1", default-features = false, features = [ - "macros", - "sync", - "rt", - "process", -] } +strum = { version = "0.27", default-features = false, features = ["std", "derive"] } +thiserror = { version = "2.0", default-features = false } +tokio = { version = "1.46.1", default-features = false, features = ["macros", "sync", "rt", "process"] } [profile.release.package.iota_interaction_ts] opt-level = 's' diff --git a/audit-trail-move/Move.lock b/audit-trail-move/Move.lock index 35082792..e8c017c3 100644 --- a/audit-trail-move/Move.lock +++ b/audit-trail-move/Move.lock @@ -62,6 +62,6 @@ flavor = "iota" [env.localnet] chain-id = "7e4cb32f" -original-published-id = "0x530901f47ced3c5ce5c25da30bb69122edb9ee7da9bea2efc0f19c778769dacf" -latest-published-id = "0x530901f47ced3c5ce5c25da30bb69122edb9ee7da9bea2efc0f19c778769dacf" +original-published-id = "0x621bcfab7acaee9db0eea1af9b67c49404cb38998e3aced8cc5b5a32618e44c5" +latest-published-id = "0x621bcfab7acaee9db0eea1af9b67c49404cb38998e3aced8cc5b5a32618e44c5" published-version = "1" diff --git a/audit-trail-move/sources/audit_trail.move b/audit-trail-move/sources/audit_trail.move index 7cc20512..fdacd3ba 100644 --- a/audit-trail-move/sources/audit_trail.move +++ b/audit-trail-move/sources/audit_trail.move @@ -10,7 +10,7 @@ module audit_trail::main; use audit_trail::{ locking::{Self, LockingConfig, LockingWindow, set_delete_record}, permission::{Self, Permission}, - record::{Self, Record, RecordCorrection}, + record::{Self, Record} }; use iota::{clock::{Self, Clock}, event, linked_table::{Self, LinkedTable}, vec_set::VecSet}; use std::string::String; @@ -20,11 +20,10 @@ use tf_components::{capability::Capability, role_map::{Self, RoleMap}}; #[error] const ERecordNotFound: vector = b"Record not found at the given sequence number"; #[error] -const EPermissionDenied: vector = - b"The role associated with the provided capability does not have the required permission"; -#[error] const ERecordLocked: vector = b"The record is locked and cannot be deleted"; #[error] +const ETrailNotEmpty: vector = b"Audit trail cannot be deleted while records still exist"; +#[error] const EPackageVersionMismatch: vector = b"The package version of the trail does not match the expected version"; @@ -178,7 +177,7 @@ public fun create( 0, creator, timestamp, - record::new_correction(), + record::new_correction(), ); linked_table::push_back(&mut records, 0, record); @@ -250,17 +249,14 @@ entry fun migrate( ctx: &mut TxContext, ) { assert!(trail.version < PACKAGE_VERSION, EPackageVersionMismatch); - assert!( - trail - .roles - .is_capability_valid( - cap, - &permission::migrate_audit_trail(), - clock, - ctx, - ), - EPermissionDenied, - ); + trail + .roles + .is_capability_valid( + cap, + &permission::migrate_audit_trail(), + clock, + ctx, + ); trail.version = PACKAGE_VERSION; } @@ -278,17 +274,14 @@ public fun add_record( ctx: &mut TxContext, ) { assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); - assert!( - trail - .roles - .is_capability_valid( - cap, - &permission::add_record(), - clock, - ctx, - ), - EPermissionDenied, - ); + trail + .roles + .is_capability_valid( + cap, + &permission::add_record(), + clock, + ctx, + ); let caller = ctx.sender(); let timestamp = clock::timestamp_ms(clock); @@ -327,17 +320,14 @@ public fun delete_record( ctx: &mut TxContext, ) { assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); - assert!( - trail - .roles - .is_capability_valid( - cap, - &permission::delete_record(), - clock, - ctx, - ), - EPermissionDenied, - ); + trail + .roles + .is_capability_valid( + cap, + &permission::delete_record(), + clock, + ctx, + ); assert!(linked_table::contains(&trail.records, sequence_number), ERecordNotFound); assert!(!trail.is_record_locked(sequence_number, clock), ERecordLocked); @@ -356,6 +346,92 @@ public fun delete_record( }); } +/// Delete up to `limit` records from the front of the trail. +/// +/// Requires `DeleteAllRecords` permission. This operation bypasses record locks. +/// Returns the number of records deleted in this batch. +public fun delete_records_batch( + trail: &mut AuditTrail, + cap: &Capability, + limit: u64, + clock: &Clock, + ctx: &mut TxContext, +): u64 { + assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); + trail + .roles + .is_capability_valid( + cap, + &permission::delete_all_records(), + clock, + ctx, + ); + + let mut deleted = 0; + let caller = ctx.sender(); + let timestamp = clock.timestamp_ms(); + let trail_id = trail.id(); + + while (deleted < limit && !trail.records.is_empty()) { + let (sequence_number, record) = trail.records.pop_front(); + + record.destroy(); + + event::emit(RecordDeleted { + trail_id, + sequence_number, + deleted_by: caller, + timestamp, + }); + + deleted = deleted + 1; + }; + + deleted +} + +/// Delete an empty audit trail. +/// +/// Requires `DeleteAuditTrail` permission and aborts if records still exist. +public fun delete_audit_trail( + trail: AuditTrail, + cap: &Capability, + clock: &Clock, + ctx: &mut TxContext, +) { + assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); + trail + .roles + .is_capability_valid( + cap, + &permission::delete_audit_trail(), + clock, + ctx, + ); + assert!(linked_table::is_empty(&trail.records), ETrailNotEmpty); + + let trail_id = trail.id(); + let timestamp = clock::timestamp_ms(clock); + + let AuditTrail { + id, + creator: _, + created_at: _, + sequence_number: _, + records, + locking_config: _, + roles: _, + immutable_metadata: _, + updatable_metadata: _, + version: _, + } = trail; + + linked_table::destroy_empty(records); + object::delete(id); + + event::emit(AuditTrailDeleted { trail_id, timestamp }); +} + // ===== Locking ===== /// Check if a record is locked based on the trail's locking configuration. @@ -388,17 +464,14 @@ public fun update_locking_config( ctx: &mut TxContext, ) { assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); - assert!( - trail - .roles - .is_capability_valid( - cap, - &permission::update_locking_config(), - clock, - ctx, - ), - EPermissionDenied, - ); + trail + .roles + .is_capability_valid( + cap, + &permission::update_locking_config(), + clock, + ctx, + ); trail.locking_config = new_config; } @@ -411,17 +484,14 @@ public fun update_locking_config_for_delete_record( ctx: &mut TxContext, ) { assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); - assert!( - trail - .roles - .is_capability_valid( - cap, - &permission::update_locking_config_for_delete_record(), - clock, - ctx, - ), - EPermissionDenied, - ); + trail + .roles + .is_capability_valid( + cap, + &permission::update_locking_config_for_delete_record(), + clock, + ctx, + ); set_delete_record(&mut trail.locking_config, new_delete_record_lock); } @@ -434,17 +504,14 @@ public fun update_metadata( ctx: &mut TxContext, ) { assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); - assert!( - trail - .roles - .is_capability_valid( - cap, - &permission::update_metadata(), - clock, - ctx, - ), - EPermissionDenied, - ); + trail + .roles + .is_capability_valid( + cap, + &permission::update_metadata(), + clock, + ctx, + ); trail.updatable_metadata = new_metadata; } @@ -566,17 +633,14 @@ public fun destroy_capability( ctx: &mut TxContext, ) { assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); - assert!( - trail - .roles - .is_capability_valid( - cap, - &permission::revoke_capabilities(), - clock, - ctx, - ), - EPermissionDenied, - ); + trail + .roles + .is_capability_valid( + cap, + &permission::revoke_capabilities(), + clock, + ctx, + ); role_map::destroy_capability(trail.roles_mut(), cap_to_destroy); } diff --git a/audit-trail-move/sources/permission.move b/audit-trail-move/sources/permission.move index df23c79c..e6202425 100644 --- a/audit-trail-move/sources/permission.move +++ b/audit-trail-move/sources/permission.move @@ -11,6 +11,8 @@ public enum Permission has copy, drop, store { // --- Whole Audit Trail related - Proposed role: `Admin` --- /// Destroy the whole Audit Trail object DeleteAuditTrail, + /// Delete records in batches for cleanup workflows + DeleteAllRecords, // --- Record Management - Proposed role: `RecordAdmin` --- /// Add records to the trail AddRecord, @@ -73,12 +75,11 @@ public fun has_permission(set: &VecSet, perm: &Permission): bool { vec_set::contains(set, perm) } -// --------------------------- Functions creating permission sets for often used roles --------------------------- +// ------Functions creating permission sets for often used roles --------- /// Create permissions typically used for the `Admin` role public fun admin_permissions(): VecSet { let mut perms = vec_set::empty(); - perms.insert(delete_audit_trail()); perms.insert(add_capabilities()); perms.insert(revoke_capabilities()); perms.insert(add_roles()); @@ -137,6 +138,11 @@ public fun delete_audit_trail(): Permission { Permission::DeleteAuditTrail } +/// Returns a permission allowing to delete records in batches +public fun delete_all_records(): Permission { + Permission::DeleteAllRecords +} + /// Returns a permission allowing to add records to the trail public fun add_record(): Permission { Permission::AddRecord @@ -205,4 +211,4 @@ public fun delete_metadata(): Permission { /// Returns a permission allowing to migrate the audit trail to a new version of the contract public fun migrate_audit_trail(): Permission { Permission::Migrate -} \ No newline at end of file +} diff --git a/audit-trail-move/sources/record.move b/audit-trail-move/sources/record.move index e47c7dbd..69ead0fd 100644 --- a/audit-trail-move/sources/record.move +++ b/audit-trail-move/sources/record.move @@ -7,7 +7,7 @@ /// LinkedTable and addressed by trail_id + sequence_number. module audit_trail::record; -use iota::vec_set::{VecSet, Self}; +use iota::vec_set::{Self, VecSet}; use std::string::String; /// A single record in the audit trail @@ -91,7 +91,6 @@ public(package) fun destroy(record: Record) { } = record; } - /// Bidirectional correction tracking for audit records public struct RecordCorrection has copy, drop, store { replaces: VecSet, diff --git a/audit-trail-move/tests/locking_tests.move b/audit-trail-move/tests/locking_tests.move index 166580b7..e608e410 100644 --- a/audit-trail-move/tests/locking_tests.move +++ b/audit-trail-move/tests/locking_tests.move @@ -4,7 +4,7 @@ module audit_trail::locking_tests; use audit_trail::{ locking, - main::AuditTrail, + main::{Self, AuditTrail}, permission, test_utils::{ Self, @@ -973,3 +973,240 @@ fun test_count_based_locking_old_record_can_delete() { ts::end(scenario); } + +#[test] +fun test_delete_records_batch_bypasses_record_lock() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + // Create trail with 1 hour delete lock and an initial record. + { + let locking_config = locking::new(locking::window_time_based(3600)); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::some(new_test_data(1, b"Locked")), + ); + transfer::public_transfer(admin_cap, admin); + }; + + ts::next_tx(&mut scenario, admin); + { + let (admin_cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); + let delete_all_role = string::utf8(b"DeleteAllRecordsAdmin"); + let delete_all_perms = permission::from_vec(vector[permission::delete_all_records()]); + + trail + .roles_mut() + .create_role( + &admin_cap, + delete_all_role, + delete_all_perms, + &clock, + ts::ctx(&mut scenario), + ); + + let delete_all_cap = test_utils::new_capability_without_restrictions( + trail.roles_mut(), + &admin_cap, + &string::utf8(b"DeleteAllRecordsAdmin"), + &clock, + ts::ctx(&mut scenario), + ); + + // Stay inside the lock window; direct delete_record would fail. + clock.set_for_testing(initial_time_for_testing() + 1000); + let deleted = trail.delete_records_batch( + &delete_all_cap, + 10, + &clock, + ts::ctx(&mut scenario), + ); + assert!(deleted == 1, 0); + assert!(trail.record_count() == 0, 1); + + delete_all_cap.destroy_for_testing(); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + }; + + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = audit_trail::role_map::ECapabilityPermissionDenied)] +fun test_delete_records_batch_requires_delete_all_records_permission() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + { + let locking_config = locking::new(locking::window_none()); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::some(new_test_data(1, b"Record")), + ); + transfer::public_transfer(admin_cap, admin); + }; + + // Create a role that has DeleteAuditTrail but NOT DeleteAllRecords. + ts::next_tx(&mut scenario, admin); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + let perms = permission::from_vec(vector[permission::delete_audit_trail()]); + trail + .roles_mut() + .create_role( + &admin_cap, + string::utf8(b"TrailDeleteOnly"), + perms, + &clock, + ts::ctx(&mut scenario), + ); + + let delete_only_cap = test_utils::new_capability_without_restrictions( + trail.roles_mut(), + &admin_cap, + &string::utf8(b"TrailDeleteOnly"), + &clock, + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(delete_only_cap, admin); + admin_cap.destroy_for_testing(); + cleanup_trail_and_clock(trail, clock); + }; + + // Must fail: delete_records_batch requires DeleteAllRecords specifically. + ts::next_tx(&mut scenario, admin); + { + let delete_only_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + clock.set_for_testing(initial_time_for_testing() + 1000); + + trail.delete_records_batch(&delete_only_cap, 10, &clock, ts::ctx(&mut scenario)); + + clock::destroy_for_testing(clock); + delete_only_cap.destroy_for_testing(); + ts::return_shared(trail); + }; + + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = audit_trail::main::ETrailNotEmpty)] +fun test_delete_audit_trail_fails_while_not_empty() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + { + let locking_config = locking::new(locking::window_none()); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::some(new_test_data(1, b"Record")), + ); + transfer::public_transfer(admin_cap, admin); + }; + + ts::next_tx(&mut scenario, admin); + { + let admin_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + let clock = clock::create_for_testing(ts::ctx(&mut scenario)); + + let delete_trail_role = string::utf8(b"DeleteTrailOnly"); + let delete_trail_perms = permission::from_vec(vector[permission::delete_audit_trail()]); + trail + .roles_mut() + .create_role( + &admin_cap, + delete_trail_role, + delete_trail_perms, + &clock, + ts::ctx(&mut scenario), + ); + + let delete_trail_cap = test_utils::new_capability_without_restrictions( + trail.roles_mut(), + &admin_cap, + &string::utf8(b"DeleteTrailOnly"), + &clock, + ts::ctx(&mut scenario), + ); + + main::delete_audit_trail(trail, &delete_trail_cap, &clock, ts::ctx(&mut scenario)); + + clock::destroy_for_testing(clock); + delete_trail_cap.destroy_for_testing(); + admin_cap.destroy_for_testing(); + }; + + ts::end(scenario); +} + +#[test] +fun test_delete_audit_trail_after_batch_cleanup() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + { + let locking_config = locking::new(locking::window_none()); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::some(new_test_data(1, b"Record")), + ); + transfer::public_transfer(admin_cap, admin); + }; + + ts::next_tx(&mut scenario, admin); + { + let admin_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + let delete_maintenance_role = string::utf8(b"DeleteMaintenance"); + let delete_maintenance_perms = permission::from_vec(vector[ + permission::delete_all_records(), + permission::delete_audit_trail(), + ]); + + trail + .roles_mut() + .create_role( + &admin_cap, + delete_maintenance_role, + delete_maintenance_perms, + &clock, + ts::ctx(&mut scenario), + ); + + let delete_maintenance_cap = test_utils::new_capability_without_restrictions( + trail.roles_mut(), + &admin_cap, + &string::utf8(b"DeleteMaintenance"), + &clock, + ts::ctx(&mut scenario), + ); + + clock.set_for_testing(initial_time_for_testing() + 1000); + let deleted = trail.delete_records_batch( + &delete_maintenance_cap, + 100, + &clock, + ts::ctx(&mut scenario), + ); + assert!(deleted == 1, 0); + assert!(trail.record_count() == 0, 1); + + main::delete_audit_trail(trail, &delete_maintenance_cap, &clock, ts::ctx(&mut scenario)); + + clock::destroy_for_testing(clock); + delete_maintenance_cap.destroy_for_testing(); + admin_cap.destroy_for_testing(); + }; + + ts::end(scenario); +} diff --git a/audit-trail-rs/Cargo.toml b/audit-trail-rs/Cargo.toml index 7902a1cd..2077a7e1 100644 --- a/audit-trail-rs/Cargo.toml +++ b/audit-trail-rs/Cargo.toml @@ -20,10 +20,10 @@ iota_interaction = { workspace = true, default-features = false } product_common = { workspace = true, default-features = false, features = ["transaction"] } secret-storage = { workspace = true, default-features = false } serde.workspace = true +serde-aux = { workspace = true, default-features = false } serde_json.workspace = true strum.workspace = true thiserror.workspace = true -serde-aux = { workspace = true, default-features = false } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] iota_interaction_rust = { workspace = true, default-features = false } diff --git a/audit-trail-rs/src/core/locking/mod.rs b/audit-trail-rs/src/core/locking/mod.rs index ae8afa67..f967d676 100644 --- a/audit-trail-rs/src/core/locking/mod.rs +++ b/audit-trail-rs/src/core/locking/mod.rs @@ -38,10 +38,7 @@ impl<'a, C> TrailLocking<'a, C> { TransactionBuilder::new(UpdateLockingConfig::new(self.trail_id, owner, config)) } - pub fn update_delete_record_window( - &self, - window: LockingWindow, - ) -> TransactionBuilder + pub fn update_delete_record_window(&self, window: LockingWindow) -> TransactionBuilder where C: AuditTrailFull + CoreClient, S: Signer + OptionalSync, diff --git a/audit-trail-rs/src/core/locking/transactions.rs b/audit-trail-rs/src/core/locking/transactions.rs index ee630b33..c2a929b5 100644 --- a/audit-trail-rs/src/core/locking/transactions.rs +++ b/audit-trail-rs/src/core/locking/transactions.rs @@ -59,18 +59,6 @@ impl Transaction for UpdateLockingConfig { { Ok(()) } - - async fn apply_with_events( - self, - effects: &mut IotaTransactionBlockEffects, - _events: &mut IotaTransactionBlockEvents, - client: &C, - ) -> Result - where - C: CoreClientReadOnly + OptionalSync, - { - self.apply(effects, client).await - } } #[derive(Debug, Clone)] @@ -118,16 +106,4 @@ impl Transaction for UpdateDeleteRecordWindow { { Ok(()) } - - async fn apply_with_events( - self, - effects: &mut IotaTransactionBlockEffects, - _events: &mut IotaTransactionBlockEvents, - client: &C, - ) -> Result - where - C: CoreClientReadOnly + OptionalSync, - { - self.apply(effects, client).await - } } diff --git a/audit-trail-rs/src/core/records/mod.rs b/audit-trail-rs/src/core/records/mod.rs index 80e2e051..7509b050 100644 --- a/audit-trail-rs/src/core/records/mod.rs +++ b/audit-trail-rs/src/core/records/mod.rs @@ -22,7 +22,7 @@ use crate::error::Error; mod operations; mod transactions; -pub use transactions::{AddRecord, DeleteRecord}; +pub use transactions::{AddRecord, DeleteRecord, DeleteRecordsBatch}; use self::operations::RecordsOps; @@ -72,6 +72,15 @@ impl<'a, C, D> TrailRecords<'a, C, D> { TransactionBuilder::new(DeleteRecord::new(self.trail_id, owner, sequence_number)) } + pub fn delete_records_batch(&self, limit: u64) -> TransactionBuilder + where + C: AuditTrailFull + CoreClient, + S: Signer + OptionalSync, + { + let owner = self.client.sender_address(); + TransactionBuilder::new(DeleteRecordsBatch::new(self.trail_id, owner, limit)) + } + pub async fn correct(&self, _replaces: Vec, _data: D, _metadata: Option) -> Result<(), Error> where C: AuditTrailFull, diff --git a/audit-trail-rs/src/core/records/operations.rs b/audit-trail-rs/src/core/records/operations.rs index 38c656da..d2efc31c 100644 --- a/audit-trail-rs/src/core/records/operations.rs +++ b/audit-trail-rs/src/core/records/operations.rs @@ -65,6 +65,30 @@ impl RecordsOps { .await } + pub(super) async fn delete_records_batch( + client: &C, + trail_id: ObjectID, + owner: IotaAddress, + limit: u64, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + operations::build_trail_transaction( + client, + trail_id, + owner, + Permission::DeleteAllRecords, + "delete_records_batch", + |ptb, _| { + let limit_arg = utils::ptb_pure(ptb, "limit", limit)?; + let clock = utils::get_clock_ref(ptb); + Ok(vec![limit_arg, clock]) + }, + ) + .await + } + pub(super) async fn get_record( client: &C, trail_id: ObjectID, diff --git a/audit-trail-rs/src/core/records/transactions.rs b/audit-trail-rs/src/core/records/transactions.rs index 5f115a63..d8bd6d83 100644 --- a/audit-trail-rs/src/core/records/transactions.rs +++ b/audit-trail-rs/src/core/records/transactions.rs @@ -73,13 +73,13 @@ impl Transaction for AddRecord { where C: CoreClientReadOnly + OptionalSync, { - for data in &events.data { - if let Ok(event) = serde_json::from_value::>(data.parsed_json.clone()) { - return Ok(event.data); - } - } + let event = events + .data + .iter() + .find_map(|data| serde_json::from_value::>(data.parsed_json.clone()).ok()) + .ok_or_else(|| Error::UnexpectedApiResponse("RecordAdded event not found".to_string()))?; - Err(Error::UnexpectedApiResponse("RecordAdded event not found".to_string())) + Ok(event.data) } async fn apply(mut self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result @@ -140,15 +140,13 @@ impl Transaction for DeleteRecord { where C: CoreClientReadOnly + OptionalSync, { - for data in &events.data { - if let Ok(event) = serde_json::from_value::>(data.parsed_json.clone()) { - return Ok(event.data); - } - } + let event = events + .data + .iter() + .find_map(|data| serde_json::from_value::>(data.parsed_json.clone()).ok()) + .ok_or_else(|| Error::UnexpectedApiResponse("RecordDeleted event not found".to_string()))?; - Err(Error::UnexpectedApiResponse( - "RecordDeleted event not found".to_string(), - )) + Ok(event.data) } async fn apply(mut self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result @@ -158,3 +156,70 @@ impl Transaction for DeleteRecord { unreachable!() } } + +// ===== DeleteRecordsBatch ===== + +#[derive(Debug, Clone)] +pub struct DeleteRecordsBatch { + pub trail_id: ObjectID, + pub owner: IotaAddress, + pub limit: u64, + cached_ptb: OnceCell, +} + +impl DeleteRecordsBatch { + pub fn new(trail_id: ObjectID, owner: IotaAddress, limit: u64) -> Self { + Self { + trail_id, + owner, + limit, + cached_ptb: OnceCell::new(), + } + } + + async fn make_ptb(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + RecordsOps::delete_records_batch(client, self.trail_id, self.owner, self.limit).await + } +} + +#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] +#[cfg_attr(feature = "send-sync", async_trait)] +impl Transaction for DeleteRecordsBatch { + type Error = Error; + type Output = u64; + + async fn build_programmable_transaction(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() + } + + async fn apply_with_events( + self, + _: &mut IotaTransactionBlockEffects, + events: &mut IotaTransactionBlockEvents, + _: &C, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + let deleted = events + .data + .iter() + .filter_map(|data| serde_json::from_value::>(data.parsed_json.clone()).ok()) + .count() as u64; + + Ok(deleted) + } + + async fn apply(self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + unreachable!() + } +} diff --git a/audit-trail-rs/src/core/roles/transactions.rs b/audit-trail-rs/src/core/roles/transactions.rs index 40eda513..30ab9299 100644 --- a/audit-trail-rs/src/core/roles/transactions.rs +++ b/audit-trail-rs/src/core/roles/transactions.rs @@ -163,14 +163,10 @@ impl Transaction for UpdateRole { where C: CoreClientReadOnly + OptionalSync, { - Err(Error::UnexpectedApiResponse( - "RoleUpdated output requires transaction events".to_string(), - )) + unreachable!() } } -// ===== DeleteRole ===== - #[derive(Debug, Clone)] pub struct DeleteRole { trail_id: ObjectID, @@ -232,14 +228,10 @@ impl Transaction for DeleteRole { where C: CoreClientReadOnly + OptionalSync, { - Err(Error::UnexpectedApiResponse( - "RoleDeleted output requires transaction events".to_string(), - )) + unreachable!() } } -// ===== IssueCapability ===== - #[derive(Debug, Clone)] pub struct IssueCapability { trail_id: ObjectID, @@ -297,29 +289,23 @@ impl Transaction for IssueCapability { where C: CoreClientReadOnly + OptionalSync, { - for data in &events.data { - if let Ok(event) = serde_json::from_value::>(data.parsed_json.clone()) { - return Ok(event.data); - } - } + let event = events + .data + .iter() + .find_map(|data| serde_json::from_value::>(data.parsed_json.clone()).ok()) + .ok_or_else(|| Error::UnexpectedApiResponse("CapabilityIssued event not found".to_string()))?; - Err(Error::UnexpectedApiResponse( - "CapabilityIssued event not found".to_string(), - )) + Ok(event.data) } async fn apply(mut self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result where C: CoreClientReadOnly + OptionalSync, { - Err(Error::UnexpectedApiResponse( - "CapabilityIssued output requires transaction events".to_string(), - )) + unreachable!() } } -// ===== RevokeCapability ===== - #[derive(Debug, Clone)] pub struct RevokeCapability { trail_id: ObjectID, @@ -368,29 +354,23 @@ impl Transaction for RevokeCapability { where C: CoreClientReadOnly + OptionalSync, { - for data in &events.data { - if let Ok(event) = serde_json::from_value::>(data.parsed_json.clone()) { - return Ok(event.data); - } - } + let event = events + .data + .iter() + .find_map(|data| serde_json::from_value::>(data.parsed_json.clone()).ok()) + .ok_or_else(|| Error::UnexpectedApiResponse("CapabilityRevoked event not found".to_string()))?; - Err(Error::UnexpectedApiResponse( - "CapabilityRevoked event not found".to_string(), - )) + Ok(event.data) } async fn apply(mut self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result where C: CoreClientReadOnly + OptionalSync, { - Err(Error::UnexpectedApiResponse( - "CapabilityRevoked output requires transaction events".to_string(), - )) + unreachable!() } } -// ===== DestroyCapability ===== - #[derive(Debug, Clone)] pub struct DestroyCapability { trail_id: ObjectID, @@ -439,24 +419,20 @@ impl Transaction for DestroyCapability { where C: CoreClientReadOnly + OptionalSync, { - for data in &events.data { - if let Ok(event) = serde_json::from_value::>(data.parsed_json.clone()) { - return Ok(event.data); - } - } + let event = events + .data + .iter() + .find_map(|data| serde_json::from_value::>(data.parsed_json.clone()).ok()) + .ok_or_else(|| Error::UnexpectedApiResponse("CapabilityDestroyed event not found".to_string()))?; - Err(Error::UnexpectedApiResponse( - "CapabilityDestroyed event not found".to_string(), - )) + Ok(event.data) } async fn apply(mut self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result where C: CoreClientReadOnly + OptionalSync, { - Err(Error::UnexpectedApiResponse( - "CapabilityDestroyed output requires transaction events".to_string(), - )) + unreachable!() } } @@ -508,24 +484,20 @@ impl Transaction for DestroyInitialAdminCapability { where C: CoreClientReadOnly + OptionalSync, { - for data in &events.data { - if let Ok(event) = serde_json::from_value::>(data.parsed_json.clone()) { - return Ok(event.data); - } - } + let event = events + .data + .iter() + .find_map(|data| serde_json::from_value::>(data.parsed_json.clone()).ok()) + .ok_or_else(|| Error::UnexpectedApiResponse("CapabilityDestroyed event not found".to_string()))?; - Err(Error::UnexpectedApiResponse( - "CapabilityDestroyed event not found".to_string(), - )) + Ok(event.data) } async fn apply(mut self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result where C: CoreClientReadOnly + OptionalSync, { - Err(Error::UnexpectedApiResponse( - "CapabilityDestroyed output requires transaction events".to_string(), - )) + unreachable!() } } @@ -579,23 +551,19 @@ impl Transaction for RevokeInitialAdminCapability { where C: CoreClientReadOnly + OptionalSync, { - for data in &events.data { - if let Ok(event) = serde_json::from_value::>(data.parsed_json.clone()) { - return Ok(event.data); - } - } + let event = events + .data + .iter() + .find_map(|data| serde_json::from_value::>(data.parsed_json.clone()).ok()) + .ok_or_else(|| Error::UnexpectedApiResponse("CapabilityRevoked event not found".to_string()))?; - Err(Error::UnexpectedApiResponse( - "CapabilityRevoked event not found".to_string(), - )) + Ok(event.data) } async fn apply(mut self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result where C: CoreClientReadOnly + OptionalSync, { - Err(Error::UnexpectedApiResponse( - "CapabilityRevoked output requires transaction events".to_string(), - )) + unreachable!() } } diff --git a/audit-trail-rs/src/core/trail.rs b/audit-trail-rs/src/core/trail.rs index c6a02c1c..8b500191 100644 --- a/audit-trail-rs/src/core/trail.rs +++ b/audit-trail-rs/src/core/trail.rs @@ -21,7 +21,7 @@ use crate::error::Error; mod operations; mod transactions; -pub use transactions::{Migrate, UpdateMetadata}; +pub use transactions::{DeleteAuditTrail, Migrate, UpdateMetadata}; /// Marker trait for read-only audit trail clients. #[doc(hidden)] @@ -75,6 +75,18 @@ impl<'a, C> AuditTrailHandle<'a, C> { TransactionBuilder::new(Migrate::new(self.trail_id, owner)) } + /// Deletes the audit trail object. + /// + /// The trail must be empty before deletion. + pub fn delete_audit_trail(&self) -> TransactionBuilder + where + C: AuditTrailFull + CoreClient, + S: Signer + OptionalSync, + { + let owner = self.client.sender_address(); + TransactionBuilder::new(DeleteAuditTrail::new(self.trail_id, owner)) + } + pub fn records(&self) -> TrailRecords<'a, C, Data> { TrailRecords::new(self.client, self.trail_id) } diff --git a/audit-trail-rs/src/core/trail/operations.rs b/audit-trail-rs/src/core/trail/operations.rs index da3f4091..d5e9bca0 100644 --- a/audit-trail-rs/src/core/trail/operations.rs +++ b/audit-trail-rs/src/core/trail/operations.rs @@ -51,4 +51,26 @@ impl TrailOps { ) .await } + + pub(super) async fn delete_audit_trail( + client: &C, + trail_id: ObjectID, + owner: IotaAddress, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + operations::build_trail_transaction( + client, + trail_id, + owner, + Permission::DeleteAuditTrail, + "delete_audit_trail", + |ptb, _| { + let clock = utils::get_clock_ref(ptb); + Ok(vec![clock]) + }, + ) + .await + } } diff --git a/audit-trail-rs/src/core/trail/transactions.rs b/audit-trail-rs/src/core/trail/transactions.rs index fb548e0f..4f12359c 100644 --- a/audit-trail-rs/src/core/trail/transactions.rs +++ b/audit-trail-rs/src/core/trail/transactions.rs @@ -3,7 +3,7 @@ use async_trait::async_trait; use iota_interaction::OptionalSync; -use iota_interaction::rpc_types::IotaTransactionBlockEffects; +use iota_interaction::rpc_types::{IotaTransactionBlockEffects, IotaTransactionBlockEvents}; use iota_interaction::types::base_types::{IotaAddress, ObjectID}; use iota_interaction::types::transaction::ProgrammableTransaction; use product_common::core_client::CoreClientReadOnly; @@ -11,6 +11,7 @@ use product_common::transaction::transaction_builder::Transaction; use tokio::sync::OnceCell; use super::operations::TrailOps; +use crate::core::types::{AuditTrailDeleted, Event}; use crate::error::Error; #[derive(Debug, Clone)] @@ -104,3 +105,66 @@ impl Transaction for UpdateMetadata { Ok(()) } } + +#[derive(Debug, Clone)] +pub struct DeleteAuditTrail { + trail_id: ObjectID, + owner: IotaAddress, + cached_ptb: OnceCell, +} + +impl DeleteAuditTrail { + pub fn new(trail_id: ObjectID, owner: IotaAddress) -> Self { + Self { + trail_id, + owner, + cached_ptb: OnceCell::new(), + } + } + + async fn make_ptb(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + TrailOps::delete_audit_trail(client, self.trail_id, self.owner).await + } +} + +#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] +#[cfg_attr(feature = "send-sync", async_trait)] +impl Transaction for DeleteAuditTrail { + type Error = Error; + type Output = AuditTrailDeleted; + + async fn build_programmable_transaction(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() + } + + async fn apply_with_events( + self, + _: &mut IotaTransactionBlockEffects, + events: &mut IotaTransactionBlockEvents, + _: &C, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + let event = events + .data + .iter() + .find_map(|data| serde_json::from_value::>(data.parsed_json.clone()).ok()) + .ok_or_else(|| Error::UnexpectedApiResponse("Expected AuditTrailDeleted event not found".to_string()))?; + + Ok(event.data) + } + + async fn apply(self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + unreachable!() + } +} diff --git a/audit-trail-rs/src/core/types/permission.rs b/audit-trail-rs/src/core/types/permission.rs index d28460cd..8f934b79 100644 --- a/audit-trail-rs/src/core/types/permission.rs +++ b/audit-trail-rs/src/core/types/permission.rs @@ -1,22 +1,23 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use crate::error::Error; +use std::collections::HashSet; +use std::str::FromStr; + use iota_interaction::ident_str; -use iota_interaction::types::Identifier; -use iota_interaction::types::TypeTag; use iota_interaction::types::base_types::{IotaAddress, ObjectID}; use iota_interaction::types::programmable_transaction_builder::ProgrammableTransactionBuilder as Ptb; -use iota_interaction::types::transaction::Argument; -use iota_interaction::types::transaction::{Command, ObjectArg, ProgrammableTransaction}; +use iota_interaction::types::transaction::{Argument, Command, ObjectArg, ProgrammableTransaction}; +use iota_interaction::types::{Identifier, TypeTag}; use serde::{Deserialize, Serialize}; -use std::collections::HashSet; -use std::str::FromStr; + +use crate::error::Error; /// Permission enum matching the Move permission module. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)] pub enum Permission { DeleteAuditTrail, + DeleteAllRecords, AddRecord, DeleteRecord, CorrectRecord, @@ -38,6 +39,7 @@ impl Permission { pub(crate) fn function_name(&self) -> &'static str { match self { Self::DeleteAuditTrail => "delete_audit_trail", + Self::DeleteAllRecords => "delete_all_records", Self::AddRecord => "add_record", Self::DeleteRecord => "delete_record", Self::CorrectRecord => "correct_record", @@ -87,7 +89,6 @@ impl PermissionSet { pub fn admin_permissions() -> Self { Self { permissions: HashSet::from([ - Permission::DeleteAuditTrail, Permission::AddCapabilities, Permission::RevokeCapabilities, Permission::AddRoles, diff --git a/audit-trail-rs/tests/e2e/locking.rs b/audit-trail-rs/tests/e2e/locking.rs index 005885c9..056fca34 100644 --- a/audit-trail-rs/tests/e2e/locking.rs +++ b/audit-trail-rs/tests/e2e/locking.rs @@ -4,7 +4,7 @@ use audit_trails::core::types::{CapabilityIssueOptions, Data, LockingConfig, LockingWindow, Permission}; use iota_interaction::types::base_types::ObjectID; -use crate::client::{get_funded_test_client, TestClient}; +use crate::client::{TestClient, get_funded_test_client}; async fn grant_role_capability( client: &TestClient, diff --git a/audit-trail-rs/tests/e2e/records.rs b/audit-trail-rs/tests/e2e/records.rs index c8b134ec..c507e83a 100644 --- a/audit-trail-rs/tests/e2e/records.rs +++ b/audit-trail-rs/tests/e2e/records.rs @@ -6,7 +6,7 @@ use audit_trails::error::Error; use iota_interaction::types::base_types::ObjectID; use product_common::core_client::CoreClient; -use crate::client::{get_funded_test_client, TestClient}; +use crate::client::{TestClient, get_funded_test_client}; async fn grant_role_capability( client: &TestClient, @@ -258,6 +258,70 @@ async fn delete_record_fails_while_count_locked() -> anyhow::Result<()> { Ok(()) } +#[tokio::test] +async fn delete_records_batch_respects_limit_and_deletes_oldest_first() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let created = client + .create_trail() + .with_initial_record(Data::text("batch-initial"), None) + .with_locking_config(LockingConfig { + delete_record: LockingWindow::TimeBased { seconds: 3600 }, + }) + .finish() + .build_and_execute(&client) + .await? + .output; + + let trail_id = created.trail_id; + let records = client.trail(trail_id).records(); + + grant_role_capability( + &client, + trail_id, + "BatchRecordAdmin", + [Permission::AddRecord, Permission::DeleteAllRecords], + ) + .await?; + + records + .add(Data::text("batch-second"), None) + .build_and_execute(&client) + .await?; + records + .add(Data::text("batch-third"), None) + .build_and_execute(&client) + .await?; + + assert_eq!(records.record_count().await?, 3); + + let deleted_two = records.delete_records_batch(2).build_and_execute(&client).await?.output; + assert_eq!(deleted_two, 2, "batch delete should stop at the provided limit"); + assert_eq!(records.record_count().await?, 1); + assert!(records.get(0).await.is_err(), "oldest record should be removed first"); + assert!( + records.get(1).await.is_err(), + "second oldest record should also be removed" + ); + assert_text_data(records.get(2).await?.data, "batch-third"); + + let deleted_last = records + .delete_records_batch(10) + .build_and_execute(&client) + .await? + .output; + assert_eq!(deleted_last, 1, "remaining record should be deleted"); + assert_eq!(records.record_count().await?, 0); + + let deleted_empty = records + .delete_records_batch(10) + .build_and_execute(&client) + .await? + .output; + assert_eq!(deleted_empty, 0, "deleting from an empty trail should return zero"); + + Ok(()) +} + #[tokio::test] async fn list_and_pagination_support_sparse_sequence_numbers() -> anyhow::Result<()> { let client = get_funded_test_client().await?; diff --git a/audit-trail-rs/tests/e2e/trail.rs b/audit-trail-rs/tests/e2e/trail.rs index d9c3f16d..c61712b7 100644 --- a/audit-trail-rs/tests/e2e/trail.rs +++ b/audit-trail-rs/tests/e2e/trail.rs @@ -328,3 +328,78 @@ async fn update_metadata_does_not_affect_immutable_metadata() -> anyhow::Result< Ok(()) } + +#[tokio::test] +async fn delete_audit_trail_fails_when_records_exist() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client + .create_test_trail(Data::text("trail-delete-not-empty-e2e")) + .await?; + client + .create_role(trail_id, "TrailDeleteOnly", vec![Permission::DeleteAuditTrail]) + .await?; + client + .issue_cap(trail_id, "TrailDeleteOnly", CapabilityIssueOptions::default()) + .await?; + let trail = client.trail(trail_id); + + let delete_result = trail.delete_audit_trail().build_and_execute(&client).await; + assert!(delete_result.is_err(), "deleting a non-empty trail must fail"); + + let on_chain = trail.get().await?; + assert_eq!(on_chain.id.object_id(), &trail_id); + + Ok(()) +} + +#[tokio::test] +async fn delete_records_batch_then_delete_audit_trail_roundtrip() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let created = client + .create_trail() + .with_initial_record(Data::text("trail-batch-delete-e2e"), None) + .with_locking_config(LockingConfig { + delete_record: LockingWindow::TimeBased { seconds: 3600 }, + }) + .finish() + .build_and_execute(&client) + .await? + .output; + client + .create_role( + created.trail_id, + "TrailDeleteMaintenance", + vec![Permission::DeleteAllRecords, Permission::DeleteAuditTrail], + ) + .await?; + client + .issue_cap( + created.trail_id, + "TrailDeleteMaintenance", + CapabilityIssueOptions::default(), + ) + .await?; + + let trail = client.trail(created.trail_id); + + let deleted = trail + .records() + .delete_records_batch(10) + .build_and_execute(&client) + .await? + .output; + assert_eq!(deleted, 1, "initial record should be deleted in batch"); + assert_eq!(trail.records().record_count().await?, 0); + + let deleted_trail = trail.delete_audit_trail().build_and_execute(&client).await?.output; + assert_eq!(deleted_trail.trail_id, created.trail_id); + assert!(deleted_trail.timestamp > 0); + + let fetch_deleted = trail.get().await; + assert!( + fetch_deleted.is_err(), + "trail object should no longer be readable after delete" + ); + + Ok(()) +} diff --git a/bindings/wasm/notarization_wasm/Cargo.toml b/bindings/wasm/notarization_wasm/Cargo.toml index 81380f71..9a04177b 100644 --- a/bindings/wasm/notarization_wasm/Cargo.toml +++ b/bindings/wasm/notarization_wasm/Cargo.toml @@ -40,12 +40,12 @@ git = "https://github.com/iotaledger/product-core.git" branch = "feat/emit-events-for-capabilities" package = "product_common" features = [ - "core-client", - "transaction", - "bindings", - "binding-utils", - "gas-station", - "default-http-client", + "core-client", + "transaction", + "bindings", + "binding-utils", + "gas-station", + "default-http-client", ] [dependencies.notarization] @@ -54,9 +54,7 @@ default-features = false features = ["gas-station", "default-http-client", "irl"] [target.'cfg(all(target_arch = "wasm32", not(target_os = "wasi")))'.dependencies] -getrandom = { version = "0.3", default-features = false, features = [ - "wasm_js", -] } +getrandom = { version = "0.3", default-features = false, features = ["wasm_js"] } [profile.release] opt-level = 's' @@ -74,6 +72,4 @@ empty_docs = "allow" [lints.rust] # required for current wasm_bindgen version -unexpected_cfgs = { level = "warn", check-cfg = [ - 'cfg(wasm_bindgen_unstable_test_coverage)', -] } +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(wasm_bindgen_unstable_test_coverage)'] } From 5f4fcfe779a575204246f60dfca21f5c97565cab Mon Sep 17 00:00:00 2001 From: Yasir Date: Fri, 27 Feb 2026 14:34:24 +0300 Subject: [PATCH 064/189] feat: Enhance locking mechanism with TimeLock support - Updated role_tests to include TimeLock parameters in locking configurations. - Modified CreateOps to accept tf_components_package_id for trail creation. - Introduced new transactions for updating delete trail locks and write locks. - Enhanced LockingOps with methods to update delete trail locks and write locks. - Expanded LockingConfig to include TimeLock fields for delete trail and write locks. - Added TimeLock enum with various locking strategies and serialization methods. - Updated permission types to include new locking permissions. - Refactored tests to validate new locking configurations and permissions. --- audit-trail-move/Move.lock | 18 +- audit-trail-move/Move.toml | 2 +- audit-trail-move/scripts/publish_package.sh | 2 +- audit-trail-move/sources/audit_trail.move | 57 ++++++- audit-trail-move/sources/locking.move | 117 ++++++++++--- audit-trail-move/sources/permission.move | 8 + audit-trail-move/tests/capability_tests.move | 14 +- .../tests/create_audit_trail_tests.move | 15 +- audit-trail-move/tests/locking_tests.move | 52 +++--- audit-trail-move/tests/metadata_tests.move | 8 +- audit-trail-move/tests/record_tests.move | 25 +-- audit-trail-move/tests/role_tests.move | 17 +- audit-trail-rs/src/core/create/operations.rs | 11 +- .../src/core/create/transactions.rs | 9 +- audit-trail-rs/src/core/locking/mod.rs | 22 ++- audit-trail-rs/src/core/locking/operations.rs | 67 +++++++- .../src/core/locking/transactions.rs | 98 ++++++++++- audit-trail-rs/src/core/types/locking.rs | 82 ++++++++- audit-trail-rs/src/core/types/permission.rs | 3 + audit-trail-rs/src/core/types/role_map.rs | 7 +- audit-trail-rs/src/package.rs | 11 ++ audit-trail-rs/tests/e2e/locking.rs | 160 +++++++++++++----- audit-trail-rs/tests/e2e/records.rs | 22 +-- audit-trail-rs/tests/e2e/trail.rs | 37 ++-- 24 files changed, 664 insertions(+), 200 deletions(-) mode change 100644 => 100755 audit-trail-move/scripts/publish_package.sh diff --git a/audit-trail-move/Move.lock b/audit-trail-move/Move.lock index e8c017c3..67d63353 100644 --- a/audit-trail-move/Move.lock +++ b/audit-trail-move/Move.lock @@ -2,7 +2,7 @@ [move] version = 3 -manifest_digest = "F2B4163DBAC14E8E7B58437714EE2902F08812C1A053496961F3557F0DD54E86" +manifest_digest = "A98478D66EC9631ABE28DCBEBAD8D608F813CA5A3D0856E6282E31F4FE7B20FF" deps_digest = "397E6A9F7A624706DBDFEE056CE88391A15876868FD18A88504DA74EB458D697" dependencies = [ { id = "Iota", name = "Iota" }, @@ -14,7 +14,7 @@ dependencies = [ [[move.package]] id = "Iota" -source = { git = "https://github.com/iotaledger/iota.git", rev = "e694e2ee8f2f9f0b9b03b843a24ff0f7bcff2930", subdir = "crates/iota-framework/packages/iota-framework" } +source = { git = "https://github.com/iotaledger/iota.git", rev = "22dd2a89f5b591066c6930510a227c087cc836ac", subdir = "crates/iota-framework/packages/iota-framework" } dependencies = [ { id = "MoveStdlib", name = "MoveStdlib" }, @@ -22,7 +22,7 @@ dependencies = [ [[move.package]] id = "IotaSystem" -source = { git = "https://github.com/iotaledger/iota.git", rev = "e694e2ee8f2f9f0b9b03b843a24ff0f7bcff2930", subdir = "crates/iota-framework/packages/iota-system" } +source = { git = "https://github.com/iotaledger/iota.git", rev = "22dd2a89f5b591066c6930510a227c087cc836ac", subdir = "crates/iota-framework/packages/iota-system" } dependencies = [ { id = "Iota", name = "Iota" }, @@ -31,11 +31,11 @@ dependencies = [ [[move.package]] id = "MoveStdlib" -source = { git = "https://github.com/iotaledger/iota.git", rev = "e694e2ee8f2f9f0b9b03b843a24ff0f7bcff2930", subdir = "crates/iota-framework/packages/move-stdlib" } +source = { git = "https://github.com/iotaledger/iota.git", rev = "22dd2a89f5b591066c6930510a227c087cc836ac", subdir = "crates/iota-framework/packages/move-stdlib" } [[move.package]] id = "Stardust" -source = { git = "https://github.com/iotaledger/iota.git", rev = "e694e2ee8f2f9f0b9b03b843a24ff0f7bcff2930", subdir = "crates/iota-framework/packages/stardust" } +source = { git = "https://github.com/iotaledger/iota.git", rev = "22dd2a89f5b591066c6930510a227c087cc836ac", subdir = "crates/iota-framework/packages/stardust" } dependencies = [ { id = "Iota", name = "Iota" }, @@ -44,7 +44,7 @@ dependencies = [ [[move.package]] id = "TfComponents" -source = { git = "https://github.com/iotaledger/product-core.git", rev = "feat/emit-events-for-capabilities", subdir = "components_move" } +source = { local = "../../product-core/components_move" } dependencies = [ { id = "Iota", name = "Iota" }, @@ -61,7 +61,7 @@ flavor = "iota" [env] [env.localnet] -chain-id = "7e4cb32f" -original-published-id = "0x621bcfab7acaee9db0eea1af9b67c49404cb38998e3aced8cc5b5a32618e44c5" -latest-published-id = "0x621bcfab7acaee9db0eea1af9b67c49404cb38998e3aced8cc5b5a32618e44c5" +chain-id = "372ade72" +original-published-id = "0xf6c9b7930b9a5ca728ad49493551fb128cbc54892d7a464b73720d20f2be2d46" +latest-published-id = "0xf6c9b7930b9a5ca728ad49493551fb128cbc54892d7a464b73720d20f2be2d46" published-version = "1" diff --git a/audit-trail-move/Move.toml b/audit-trail-move/Move.toml index 3f851808..c83e2eed 100644 --- a/audit-trail-move/Move.toml +++ b/audit-trail-move/Move.toml @@ -3,7 +3,7 @@ name = "audit_trail" edition = "2024.beta" [dependencies] -TfComponents = { git = "https://github.com/iotaledger/product-core.git", subdir = "components_move", rev = "feat/emit-events-for-capabilities" } +TfComponents = { local = "../../product-core/components_move" } [addresses] audit_trail = "0x0" diff --git a/audit-trail-move/scripts/publish_package.sh b/audit-trail-move/scripts/publish_package.sh old mode 100644 new mode 100755 index faebef37..b04c848f --- a/audit-trail-move/scripts/publish_package.sh +++ b/audit-trail-move/scripts/publish_package.sh @@ -6,7 +6,7 @@ script_dir=$(cd "$(dirname $0)" && pwd) package_dir=$script_dir/.. -RESPONSE=$(iota client publish --with-unpublished-dependencies --silence-warnings --json --gas-budget 500000000 $package_dir) +RESPONSE=$(iota client publish --silence-warnings --json --gas-budget 500000000 $package_dir) { # try PACKAGE_ID=$(echo $RESPONSE | jq --raw-output '.objectChanges[] | select(.type | contains("published")) | .packageId') } || { # catch diff --git a/audit-trail-move/sources/audit_trail.move b/audit-trail-move/sources/audit_trail.move index fdacd3ba..3ea14fe7 100644 --- a/audit-trail-move/sources/audit_trail.move +++ b/audit-trail-move/sources/audit_trail.move @@ -8,13 +8,14 @@ module audit_trail::main; use audit_trail::{ - locking::{Self, LockingConfig, LockingWindow, set_delete_record}, + locking::{Self, LockingConfig, LockingWindow, set_config, set_delete_record_window, set_delete_trail_lock, set_write_lock}, permission::{Self, Permission}, record::{Self, Record} }; use iota::{clock::{Self, Clock}, event, linked_table::{Self, LinkedTable}, vec_set::VecSet}; use std::string::String; use tf_components::{capability::Capability, role_map::{Self, RoleMap}}; +use tf_components::timelock::TimeLock; // ===== Errors ===== #[error] @@ -24,6 +25,10 @@ const ERecordLocked: vector = b"The record is locked and cannot be deleted"; #[error] const ETrailNotEmpty: vector = b"Audit trail cannot be deleted while records still exist"; #[error] +const ETrailDeleteLocked: vector = b"The audit trail is delete-locked"; +#[error] +const ETrailWriteLocked: vector = b"The audit trail is write-locked"; +#[error] const EPackageVersionMismatch: vector = b"The package version of the trail does not match the expected version"; @@ -282,6 +287,7 @@ public fun add_record( clock, ctx, ); + assert!(!locking::is_write_locked(&trail.locking_config, clock), ETrailWriteLocked); let caller = ctx.sender(); let timestamp = clock::timestamp_ms(clock); @@ -408,6 +414,7 @@ public fun delete_audit_trail( clock, ctx, ); + assert!(!locking::is_delete_trail_locked(&trail.locking_config, clock), ETrailDeleteLocked); assert!(linked_table::is_empty(&trail.records), ETrailNotEmpty); let trail_id = trail.id(); @@ -446,7 +453,7 @@ public fun is_record_locked( let record = linked_table::borrow(&trail.records, sequence_number); let current_time = clock::timestamp_ms(clock); - locking::is_locked( + locking::is_delete_record_locked( &trail.locking_config, sequence_number, record::added_at(record), @@ -472,11 +479,11 @@ public fun update_locking_config( clock, ctx, ); - trail.locking_config = new_config; + set_config(&mut trail.locking_config, new_config); } /// Update the `delete_record_lock` locking configuration -public fun update_locking_config_for_delete_record( +public fun update_delete_record_window( trail: &mut AuditTrail, cap: &Capability, new_delete_record_lock: LockingWindow, @@ -492,7 +499,47 @@ public fun update_locking_config_for_delete_record( clock, ctx, ); - set_delete_record(&mut trail.locking_config, new_delete_record_lock); + set_delete_record_window(&mut trail.locking_config, new_delete_record_lock); +} + +/// Update the `delete_trail_lock` locking configuration. +public fun update_delete_trail_lock( + trail: &mut AuditTrail, + cap: &Capability, + new_delete_trail_lock: TimeLock, + clock: &Clock, + ctx: &mut TxContext, +) { + assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); + trail + .roles + .is_capability_valid( + cap, + &permission::update_locking_config_for_delete_trail(), + clock, + ctx, + ); + set_delete_trail_lock(&mut trail.locking_config, new_delete_trail_lock); +} + +/// Update the `write_lock` locking configuration. +public fun update_write_lock( + trail: &mut AuditTrail, + cap: &Capability, + new_write_lock: TimeLock, + clock: &Clock, + ctx: &mut TxContext, +) { + assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); + trail + .roles + .is_capability_valid( + cap, + &permission::update_locking_config_for_write(), + clock, + ctx, + ); + set_write_lock(&mut trail.locking_config, new_write_lock); } /// Update the trail's mutable metadata diff --git a/audit-trail-move/sources/locking.move b/audit-trail-move/sources/locking.move index 11b7036e..da1d2804 100644 --- a/audit-trail-move/sources/locking.move +++ b/audit-trail-move/sources/locking.move @@ -4,6 +4,14 @@ /// Locking configuration for audit trail records module audit_trail::locking; +use iota::clock::Clock; +use tf_components::timelock::{Self, TimeLock}; + +// ===== Errors ===== + +/// UntilDestroyed cannot be used for trail deletion protection. +const EUntilDestroyedNotSupportedForDeleteTrail: u64 = 0; + /// Defines a locking window (time XOR count based, or none) public enum LockingWindow has copy, drop, store { None, @@ -12,9 +20,13 @@ public enum LockingWindow has copy, drop, store { } /// Top-level locking configuration for the audit trail -public struct LockingConfig has copy, drop, store { +public struct LockingConfig has drop, store { /// Locking rules for record deletion - delete_record: LockingWindow, + delete_record_window: LockingWindow, + /// Timelock protecting deletion of the trail itself + delete_trail_lock: TimeLock, + /// Timelock protecting record writes (add_record) + write_lock: TimeLock, } // ===== LockingWindow Constructors ===== @@ -37,14 +49,27 @@ public fun window_count_based(count: u64): LockingWindow { // ===== LockingConfig Constructors ===== /// Create a new locking configuration -public fun new(delete_record: LockingWindow): LockingConfig { - LockingConfig { delete_record } +public fun new( + delete_record_window: LockingWindow, + delete_trail_lock: TimeLock, + write_lock: TimeLock, +): LockingConfig { + assert!( + !timelock::is_until_destroyed(&delete_trail_lock), + EUntilDestroyedNotSupportedForDeleteTrail, + ); + + LockingConfig { + delete_record_window, + delete_trail_lock, + write_lock, + } } // ===== LockingWindow Getters ===== /// Get the time window in seconds (if set) -public fun time_window_seconds(window: &LockingWindow): Option { +public(package) fun time_window_seconds(window: &LockingWindow): Option { match (window) { LockingWindow::TimeBased { seconds } => option::some(*seconds), _ => option::none(), @@ -52,7 +77,7 @@ public fun time_window_seconds(window: &LockingWindow): Option { } /// Get the count window (if set) -public fun count_window(window: &LockingWindow): Option { +public(package) fun count_window(window: &LockingWindow): Option { match (window) { LockingWindow::CountBased { count } => option::some(*count), _ => option::none(), @@ -62,23 +87,60 @@ public fun count_window(window: &LockingWindow): Option { // ===== LockingConfig Getters ===== /// Get the record deletion locking window -public fun delete_record(config: &LockingConfig): &LockingWindow { - &config.delete_record +public(package) fun delete_record_window(config: &LockingConfig): &LockingWindow { + &config.delete_record_window +} + +/// Get the trail deletion timelock +public(package) fun delete_trail_lock(config: &LockingConfig): &TimeLock { + &config.delete_trail_lock +} + +/// Get the write timelock +public(package) fun write_lock(config: &LockingConfig): &TimeLock { + &config.write_lock } // ===== LockingConfig Setters ===== /// Set the record deletion locking window -public(package) fun set_delete_record(config: &mut LockingConfig, window: LockingWindow) { - config.delete_record = window; +public(package) fun set_delete_record_window(config: &mut LockingConfig, window: LockingWindow) { + config.delete_record_window = window; +} + +/// Set the trail deletion timelock. +public(package) fun set_delete_trail_lock(config: &mut LockingConfig, lock: TimeLock) { + assert!( + !timelock::is_until_destroyed(&lock), + EUntilDestroyedNotSupportedForDeleteTrail, + ); + + config.delete_trail_lock = lock; +} + +/// Set the write timelock. +public(package) fun set_write_lock(config: &mut LockingConfig, lock: TimeLock) { + config.write_lock = lock; +} + +/// Set the whole locking configuration. +public(package) fun set_config(config: &mut LockingConfig, new_config: LockingConfig) { + let LockingConfig { + delete_record_window, + delete_trail_lock, + write_lock, + } = new_config; + + set_delete_record_window(config, delete_record_window); + set_delete_trail_lock(config, delete_trail_lock); + set_write_lock(config, write_lock); } // ===== Locking Logic (LockingWindow) ===== -/// Check if a record is locked based on time window -/// -/// Returns true if the record was created within the time window -public fun is_time_locked(window: &LockingWindow, record_timestamp: u64, current_time: u64): bool { +/// Check if a record is locked based on time window. +/// Returns true if the record was created within the time window. +fun is_time_locked(window: &LockingWindow, record_timestamp: u64, current_time: u64): bool { match (window) { LockingWindow::TimeBased { seconds } => { let time_window_ms = (*seconds) * 1000; @@ -89,10 +151,9 @@ public fun is_time_locked(window: &LockingWindow, record_timestamp: u64, current } } -/// Check if a record is locked based on count window -/// -/// Returns true if the record is among the last N records -public fun is_count_locked(window: &LockingWindow, sequence_number: u64, total_records: u64): bool { +/// Check if a record is locked based on count window. +/// Returns true if the record is among the last N records. +fun is_count_locked(window: &LockingWindow, sequence_number: u64, total_records: u64): bool { match (window) { LockingWindow::CountBased { count } => { let records_after = total_records - sequence_number - 1; @@ -102,8 +163,8 @@ public fun is_count_locked(window: &LockingWindow, sequence_number: u64, total_r } } -/// Check if a record is locked by a window (either by time or count) -public fun is_window_locked( +/// Check if a record is locked by a window (either by time or count). +fun is_window_locked( window: &LockingWindow, sequence_number: u64, record_timestamp: u64, @@ -116,8 +177,8 @@ public fun is_window_locked( // ===== Locking Logic (LockingConfig) ===== -/// Check if a record is locked for deletion -public fun is_locked( +/// Check if a record is locked for deletion. +public fun is_delete_record_locked( config: &LockingConfig, sequence_number: u64, record_timestamp: u64, @@ -125,10 +186,20 @@ public fun is_locked( current_time: u64, ): bool { is_window_locked( - &config.delete_record, + &config.delete_record_window, sequence_number, record_timestamp, total_records, current_time, ) } + +/// Check if trail deletion is currently locked. +public fun is_delete_trail_locked(config: &LockingConfig, clock: &Clock): bool { + timelock::is_timelocked(delete_trail_lock(config), clock) +} + +/// Check if writes are currently locked. +public fun is_write_locked(config: &LockingConfig, clock: &Clock): bool { + timelock::is_timelocked(write_lock(config), clock) +} diff --git a/audit-trail-move/sources/permission.move b/audit-trail-move/sources/permission.move index e6202425..aeb8f03d 100644 --- a/audit-trail-move/sources/permission.move +++ b/audit-trail-move/sources/permission.move @@ -27,6 +27,8 @@ public enum Permission has copy, drop, store { UpdateLockingConfigForDeleteRecord, /// Update the delete_lock configuration for the whole Audit Trail UpdateLockingConfigForDeleteTrail, + /// Update the write_lock configuration for the whole Audit Trail + UpdateLockingConfigForWrite, // --- Role Management - Proposed role: `RoleAdmin` --- /// Add new roles with associated permissions AddRoles, @@ -103,6 +105,7 @@ public fun locking_admin_permissions(): VecSet { perms.insert(update_locking_config()); perms.insert(update_locking_config_for_delete_trail()); perms.insert(update_locking_config_for_delete_record()); + perms.insert(update_locking_config_for_write()); perms } @@ -173,6 +176,11 @@ public fun update_locking_config_for_delete_trail(): Permission { Permission::UpdateLockingConfigForDeleteTrail } +/// Returns a permission allowing to update the write_lock configuration for the whole Audit Trail +public fun update_locking_config_for_write(): Permission { + Permission::UpdateLockingConfigForWrite +} + /// Returns a permission allowing to add new roles with associated permissions public fun add_roles(): Permission { Permission::AddRoles diff --git a/audit-trail-move/tests/capability_tests.move b/audit-trail-move/tests/capability_tests.move index 03a8df07..bd01ab1a 100644 --- a/audit-trail-move/tests/capability_tests.move +++ b/audit-trail-move/tests/capability_tests.move @@ -16,7 +16,7 @@ use audit_trail::{ }; use iota::test_scenario::{Self as ts, Scenario}; use std::string; -use tf_components::capability::Capability; +use tf_components::{capability::Capability, timelock}; /// Helper function to setup an audit trail with a RecordAdmin role and a capability /// with a time window restriction transferred to the record_user. @@ -65,7 +65,7 @@ fun setup_trail_with_record_admin_capability_and_time_window_restriction( fun setup_trail_with_record_admin_role(scenario: &mut Scenario, admin_user: address): ID { // Setup: Create audit trail with admin capability let trail_id = { - let locking_config = locking::new(locking::window_count_based(0)); + let locking_config = locking::new(locking::window_count_based(0), timelock::none(), timelock::none()); let (admin_cap, trail_id) = setup_test_audit_trail( scenario, @@ -112,7 +112,7 @@ fun test_new_capability() { let mut scenario = ts::begin(admin_user); let trail_id = { - let locking_config = locking::new(locking::window_count_based(0)); + let locking_config = locking::new(locking::window_count_based(0), timelock::none(), timelock::none()); let (admin_cap, trail_id) = setup_test_audit_trail( &mut scenario, locking_config, @@ -634,7 +634,7 @@ fun test_revoked_capability_cannot_be_used() { let mut scenario = ts::begin(admin_user); { - let locking_config = locking::new(locking::window_count_based(0)); + let locking_config = locking::new(locking::window_count_based(0), timelock::none(), timelock::none()); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -713,7 +713,7 @@ fun test_new_capability_for_nonexistent_role() { let mut scenario = ts::begin(admin_user); { - let locking_config = locking::new(locking::window_count_based(0)); + let locking_config = locking::new(locking::window_count_based(0), timelock::none(), timelock::none()); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -751,7 +751,7 @@ fun test_revoke_capability_permission_denied() { let mut scenario = ts::begin(admin_user); { - let locking_config = locking::new(locking::window_count_based(0)); + let locking_config = locking::new(locking::window_count_based(0), timelock::none(), timelock::none()); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -837,7 +837,7 @@ fun test_new_capability_permission_denied() { let mut scenario = ts::begin(admin_user); { - let locking_config = locking::new(locking::window_count_based(0)); + let locking_config = locking::new(locking::window_count_based(0), timelock::none(), timelock::none()); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, diff --git a/audit-trail-move/tests/create_audit_trail_tests.move b/audit-trail-move/tests/create_audit_trail_tests.move index 5707c08d..f1f4b5e7 100644 --- a/audit-trail-move/tests/create_audit_trail_tests.move +++ b/audit-trail-move/tests/create_audit_trail_tests.move @@ -16,6 +16,7 @@ use audit_trail::{ }; use iota::{clock, test_scenario as ts}; use std::string; +use tf_components::timelock; #[test] fun test_create_without_initial_record() { @@ -23,7 +24,7 @@ fun test_create_without_initial_record() { let mut scenario = ts::begin(user); { - let locking_config = locking::new(locking::window_count_based(0)); + let locking_config = locking::new(locking::window_count_based(0), timelock::none(), timelock::none()); let (admin_cap, trail_id) = setup_test_audit_trail( &mut scenario, @@ -60,7 +61,7 @@ fun test_create_with_initial_record() { let mut scenario = ts::begin(user); { - let locking_config = locking::new(locking::window_time_based(86400)); // 1 day in seconds + let locking_config = locking::new(locking::window_time_based(86400), timelock::none(), timelock::none()); // 1 day in seconds let initial_data = new_test_data(42, b"Hello, World!"); let (admin_cap, trail_id) = setup_test_audit_trail( @@ -104,7 +105,7 @@ fun test_create_minimal_metadata() { let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); clock.set_for_testing(3000); - let locking_config = locking::new(locking::window_count_based(0)); + let locking_config = locking::new(locking::window_count_based(0), timelock::none(), timelock::none()); let (admin_cap, _trail_id) = main::create( option::none(), @@ -145,7 +146,7 @@ fun test_create_with_locking_enabled() { let mut scenario = ts::begin(user); { - let locking_config = locking::new(locking::window_time_based(604800)); // 7 days in seconds + let locking_config = locking::new(locking::window_time_based(604800), timelock::none(), timelock::none()); // 7 days in seconds let (admin_cap, _trail_id) = setup_test_audit_trail( &mut scenario, locking_config, @@ -179,7 +180,7 @@ fun test_create_multiple_trails() { // Create first trail { - let locking_config = locking::new(locking::window_count_based(0)); + let locking_config = locking::new(locking::window_count_based(0), timelock::none(), timelock::none()); let (admin_cap1, trail_id1) = setup_test_audit_trail( &mut scenario, locking_config, @@ -194,7 +195,7 @@ fun test_create_multiple_trails() { // Create second trail { - let locking_config = locking::new(locking::window_count_based(0)); + let locking_config = locking::new(locking::window_count_based(0), timelock::none(), timelock::none()); let (admin_cap2, trail_id2) = setup_test_audit_trail( &mut scenario, locking_config, @@ -220,7 +221,7 @@ fun test_create_metadata_admin_role() { // Creator creates the audit trail { - let locking_config = locking::new(locking::window_count_based(0)); + let locking_config = locking::new(locking::window_count_based(0), timelock::none(), timelock::none()); let (admin_cap, trail_id) = setup_test_audit_trail( &mut scenario, diff --git a/audit-trail-move/tests/locking_tests.move b/audit-trail-move/tests/locking_tests.move index e608e410..13b4c020 100644 --- a/audit-trail-move/tests/locking_tests.move +++ b/audit-trail-move/tests/locking_tests.move @@ -19,7 +19,7 @@ use audit_trail::{ }; use iota::{clock, test_scenario as ts}; use std::string; -use tf_components::capability::Capability; +use tf_components::{capability::Capability, timelock}; // ===== Time-Based Locking Tests ===== @@ -30,7 +30,7 @@ fun test_time_based_locking_within_window() { // Create trail with 1 hour time-based locking { - let locking_config = locking::new(locking::window_time_based(3600)); + let locking_config = locking::new(locking::window_time_based(3600), timelock::none(), timelock::none()); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -70,7 +70,7 @@ fun test_time_based_locking_outside_window() { // Create trail with 1 hour time-based locking { - let locking_config = locking::new(locking::window_time_based(3600)); + let locking_config = locking::new(locking::window_time_based(3600), timelock::none(), timelock::none()); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -108,7 +108,7 @@ fun test_count_based_locking() { // Create trail with count-based locking (last 2 locked) { - let locking_config = locking::new(locking::window_count_based(2)); + let locking_config = locking::new(locking::window_count_based(2), timelock::none(), timelock::none()); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -190,7 +190,7 @@ fun test_count_based_locking_single_record() { // Create trail with "last 3 locked" - single record should be locked { - let locking_config = locking::new(locking::window_count_based(3)); + let locking_config = locking::new(locking::window_count_based(3), timelock::none(), timelock::none()); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -222,7 +222,7 @@ fun test_no_locking() { let mut scenario = ts::begin(admin); { - let locking_config = locking::new(locking::window_none()); + let locking_config = locking::new(locking::window_none(), timelock::none(), timelock::none()); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -256,7 +256,7 @@ fun test_update_locking_config() { // Create trail with no locking { - let locking_config = locking::new(locking::window_none()); + let locking_config = locking::new(locking::window_none(), timelock::none(), timelock::none()); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -306,7 +306,7 @@ fun test_update_locking_config() { // Update to 1 hour time-based locking trail.update_locking_config( &locking_cap, - locking::new(locking::window_time_based(3600)), + locking::new(locking::window_time_based(3600), timelock::none(), timelock::none()), &clock, ts::ctx(&mut scenario), ); @@ -328,7 +328,7 @@ fun test_update_locking_config_permission_denied() { let mut scenario = ts::begin(admin); { - let locking_config = locking::new(locking::window_none()); + let locking_config = locking::new(locking::window_none(), timelock::none(), timelock::none()); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -372,7 +372,7 @@ fun test_update_locking_config_permission_denied() { trail.update_locking_config( &no_locking_cap, - locking::new(locking::window_time_based(3600)), + locking::new(locking::window_time_based(3600), timelock::none(), timelock::none()), &clock, ts::ctx(&mut scenario), ); @@ -384,13 +384,13 @@ fun test_update_locking_config_permission_denied() { } #[test] -fun test_update_locking_config_for_delete_record() { +fun test_update_delete_record_window() { let admin = @0xAD; let mut scenario = ts::begin(admin); // Create trail with no locking { - let locking_config = locking::new(locking::window_none()); + let locking_config = locking::new(locking::window_none(), timelock::none(), timelock::none()); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -442,7 +442,7 @@ fun test_update_locking_config_for_delete_record() { assert!(!trail.is_record_locked(0, &clock), 0); // Update to count-based (last 5 locked) - trail.update_locking_config_for_delete_record( + trail.update_delete_record_window( &delete_lock_cap, locking::window_count_based(5), &clock, @@ -460,12 +460,12 @@ fun test_update_locking_config_for_delete_record() { #[test] #[expected_failure(abort_code = audit_trail::role_map::ECapabilityPermissionDenied)] -fun test_update_locking_config_for_delete_record_permission_denied() { +fun test_update_delete_record_window_permission_denied() { let admin = @0xAD; let mut scenario = ts::begin(admin); { - let locking_config = locking::new(locking::window_none()); + let locking_config = locking::new(locking::window_none(), timelock::none(), timelock::none()); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -508,7 +508,7 @@ fun test_update_locking_config_for_delete_record_permission_denied() { { let (wrong_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); - trail.update_locking_config_for_delete_record( + trail.update_delete_record_window( &wrong_cap, locking::window_count_based(5), &clock, @@ -528,7 +528,7 @@ fun test_delete_record_after_time_lock_expires() { // Create trail with 1 hour time-based locking and initial record { - let locking_config = locking::new(locking::window_time_based(3600)); // 1 hour = 3600 seconds + let locking_config = locking::new(locking::window_time_based(3600), timelock::none(), timelock::none()); // 1 hour = 3600 seconds let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -615,7 +615,7 @@ fun test_time_lock_boundary_just_before_expiry() { // Create trail with 1 hour time-based locking { - let locking_config = locking::new(locking::window_time_based(3600)); + let locking_config = locking::new(locking::window_time_based(3600), timelock::none(), timelock::none()); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -650,7 +650,7 @@ fun test_time_based_locking_all_recent_records_locked() { // Create trail with time-based (1 hour) locking { - let locking_config = locking::new(locking::window_time_based(3600)); + let locking_config = locking::new(locking::window_time_based(3600), timelock::none(), timelock::none()); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -731,7 +731,7 @@ fun test_count_based_locking_last_records_remain_locked() { // Create trail with count-based (last 2) locking { - let locking_config = locking::new(locking::window_count_based(2)); + let locking_config = locking::new(locking::window_count_based(2), timelock::none(), timelock::none()); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -814,7 +814,7 @@ fun test_time_based_locking_still_locked_before_expiry() { // Create trail with time-based (1 hour) locking { - let locking_config = locking::new(locking::window_time_based(3600)); + let locking_config = locking::new(locking::window_time_based(3600), timelock::none(), timelock::none()); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -893,7 +893,7 @@ fun test_count_based_locking_old_record_can_delete() { // Create trail with count-based (last 2) locking { - let locking_config = locking::new(locking::window_count_based(2)); + let locking_config = locking::new(locking::window_count_based(2), timelock::none(), timelock::none()); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -981,7 +981,7 @@ fun test_delete_records_batch_bypasses_record_lock() { // Create trail with 1 hour delete lock and an initial record. { - let locking_config = locking::new(locking::window_time_based(3600)); + let locking_config = locking::new(locking::window_time_based(3600), timelock::none(), timelock::none()); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -1039,7 +1039,7 @@ fun test_delete_records_batch_requires_delete_all_records_permission() { let mut scenario = ts::begin(admin); { - let locking_config = locking::new(locking::window_none()); + let locking_config = locking::new(locking::window_none(), timelock::none(), timelock::none()); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -1102,7 +1102,7 @@ fun test_delete_audit_trail_fails_while_not_empty() { let mut scenario = ts::begin(admin); { - let locking_config = locking::new(locking::window_none()); + let locking_config = locking::new(locking::window_none(), timelock::none(), timelock::none()); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -1153,7 +1153,7 @@ fun test_delete_audit_trail_after_batch_cleanup() { let mut scenario = ts::begin(admin); { - let locking_config = locking::new(locking::window_none()); + let locking_config = locking::new(locking::window_none(), timelock::none(), timelock::none()); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, diff --git a/audit-trail-move/tests/metadata_tests.move b/audit-trail-move/tests/metadata_tests.move index bf091be7..f12a864c 100644 --- a/audit-trail-move/tests/metadata_tests.move +++ b/audit-trail-move/tests/metadata_tests.move @@ -14,7 +14,7 @@ use audit_trail::{ }; use iota::test_scenario as ts; use std::string; -use tf_components::capability::Capability; +use tf_components::{capability::Capability, timelock}; // ===== Success Case Tests ===== @@ -27,7 +27,7 @@ fun test_update_metadata_success() { // Setup: Create audit trail { - let locking_config = locking::new(locking::window_count_based(0)); + let locking_config = locking::new(locking::window_count_based(0), timelock::none(), timelock::none()); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -145,7 +145,7 @@ fun test_update_metadata_permission_denied() { // Setup { - let locking_config = locking::new(locking::window_count_based(0)); + let locking_config = locking::new(locking::window_count_based(0), timelock::none(), timelock::none()); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -212,7 +212,7 @@ fun test_update_metadata_revoked_capability() { // Setup: Create audit trail { - let locking_config = locking::new(locking::window_count_based(0)); + let locking_config = locking::new(locking::window_count_based(0), timelock::none(), timelock::none()); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, diff --git a/audit-trail-move/tests/record_tests.move b/audit-trail-move/tests/record_tests.move index d5b55e07..34b1950d 100644 --- a/audit-trail-move/tests/record_tests.move +++ b/audit-trail-move/tests/record_tests.move @@ -21,6 +21,7 @@ use audit_trail::{ }; use iota::{clock, test_scenario as ts}; use std::string; +use tf_components::timelock; // ===== Add Record Tests ===== @@ -31,7 +32,7 @@ fun test_add_record_to_empty_trail() { // Setup trail { - let locking_config = locking::new(locking::window_none()); + let locking_config = locking::new(locking::window_none(), timelock::none(), timelock::none()); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -105,7 +106,7 @@ fun test_add_multiple_records() { // Setup trail { - let locking_config = locking::new(locking::window_none()); + let locking_config = locking::new(locking::window_none(), timelock::none(), timelock::none()); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -182,7 +183,7 @@ fun test_add_record_permission_denied() { // Setup trail { - let locking_config = locking::new(locking::window_none()); + let locking_config = locking::new(locking::window_none(), timelock::none(), timelock::none()); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -250,7 +251,7 @@ fun test_delete_record_success() { // Setup trail with initial record { - let locking_config = locking::new(locking::window_none()); + let locking_config = locking::new(locking::window_none(), timelock::none(), timelock::none()); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -319,7 +320,7 @@ fun test_delete_record_permission_denied() { // Setup trail with initial record { - let locking_config = locking::new(locking::window_none()); + let locking_config = locking::new(locking::window_none(), timelock::none(), timelock::none()); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -380,7 +381,7 @@ fun test_delete_record_not_found() { // Setup trail (no initial record) { - let locking_config = locking::new(locking::window_none()); + let locking_config = locking::new(locking::window_none(), timelock::none(), timelock::none()); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -440,7 +441,7 @@ fun test_delete_record_time_locked() { // Setup trail with time-based locking and initial record { - let locking_config = locking::new(locking::window_time_based(3600)); // 1 hour + let locking_config = locking::new(locking::window_time_based(3600), timelock::none(), timelock::none()); // 1 hour let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -501,7 +502,7 @@ fun test_delete_record_count_locked() { // Setup trail with count-based locking and initial record { - let locking_config = locking::new(locking::window_count_based(5)); // Last 5 records locked + let locking_config = locking::new(locking::window_count_based(5), timelock::none(), timelock::none()); // Last 5 records locked let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -562,7 +563,7 @@ fun test_get_record() { // Setup trail with initial record { - let locking_config = locking::new(locking::window_none()); + let locking_config = locking::new(locking::window_none(), timelock::none(), timelock::none()); let initial_data = new_test_data(42, b"Test data"); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, @@ -596,7 +597,7 @@ fun test_get_record_not_found() { // Setup trail (no initial record) { - let locking_config = locking::new(locking::window_none()); + let locking_config = locking::new(locking::window_none(), timelock::none(), timelock::none()); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -625,7 +626,7 @@ fun test_first_last_sequence() { // Setup trail { - let locking_config = locking::new(locking::window_none()); + let locking_config = locking::new(locking::window_none(), timelock::none(), timelock::none()); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -714,7 +715,7 @@ fun test_is_record_locked_not_found() { // Setup trail (no initial record) { - let locking_config = locking::new(locking::window_time_based(3600)); + let locking_config = locking::new(locking::window_time_based(3600), timelock::none(), timelock::none()); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, diff --git a/audit-trail-move/tests/role_tests.move b/audit-trail-move/tests/role_tests.move index 1501ac7b..9c0b7108 100644 --- a/audit-trail-move/tests/role_tests.move +++ b/audit-trail-move/tests/role_tests.move @@ -16,6 +16,7 @@ use audit_trail::{ }; use iota::test_scenario as ts; use std::string; +use tf_components::timelock; #[test] fun test_role_based_permission_delegation() { @@ -28,7 +29,7 @@ fun test_role_based_permission_delegation() { // Step 1: admin_user creates the audit trail let trail_id = { - let locking_config = locking::new(locking::window_count_based(0)); + let locking_config = locking::new(locking::window_count_based(0), timelock::none(), timelock::none()); let (admin_cap, trail_id) = setup_test_audit_trail( &mut scenario, @@ -215,7 +216,7 @@ fun test_delete_role_success() { let mut scenario = ts::begin(admin_user); { - let locking_config = locking::new(locking::window_count_based(0)); + let locking_config = locking::new(locking::window_count_based(0), timelock::none(), timelock::none()); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -279,7 +280,7 @@ fun test_create_role_permission_denied() { // Setup { - let locking_config = locking::new(locking::window_count_based(0)); + let locking_config = locking::new(locking::window_count_based(0), timelock::none(), timelock::none()); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -351,7 +352,7 @@ fun test_delete_role_permission_denied() { // Setup { - let locking_config = locking::new(locking::window_count_based(0)); + let locking_config = locking::new(locking::window_count_based(0), timelock::none(), timelock::none()); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -427,7 +428,7 @@ fun test_update_role_permissions_permission_denied() { // Setup { - let locking_config = locking::new(locking::window_count_based(0)); + let locking_config = locking::new(locking::window_count_based(0), timelock::none(), timelock::none()); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -509,7 +510,7 @@ fun test_get_role_permissions_nonexistent() { let mut scenario = ts::begin(admin_user); { - let locking_config = locking::new(locking::window_count_based(0)); + let locking_config = locking::new(locking::window_count_based(0), timelock::none(), timelock::none()); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -538,7 +539,7 @@ fun test_update_role_permissions_success() { let mut scenario = ts::begin(admin_user); { - let locking_config = locking::new(locking::window_count_based(0)); + let locking_config = locking::new(locking::window_count_based(0), timelock::none(), timelock::none()); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -599,7 +600,7 @@ fun test_update_role_permissions_nonexistent() { let mut scenario = ts::begin(admin_user); { - let locking_config = locking::new(locking::window_count_based(0)); + let locking_config = locking::new(locking::window_count_based(0), timelock::none(), timelock::none()); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, diff --git a/audit-trail-rs/src/core/create/operations.rs b/audit-trail-rs/src/core/create/operations.rs index 4cc2c3dd..9fd813d8 100644 --- a/audit-trail-rs/src/core/create/operations.rs +++ b/audit-trail-rs/src/core/create/operations.rs @@ -14,7 +14,8 @@ pub(super) struct CreateOps; impl CreateOps { pub(super) fn create_trail( - package_id: ObjectID, + audit_trail_package_id: ObjectID, + tf_components_package_id: ObjectID, admin: IotaAddress, initial_data: Option, initial_record_metadata: Option, @@ -33,13 +34,13 @@ impl CreateOps { let initial_data_arg = initial_data.to_option_ptb(&mut ptb, "initial_data")?; let initial_record_metadata = utils::ptb_pure(&mut ptb, "initial_record_metadata", initial_record_metadata)?; - let locking_config = locking_config.to_ptb(&mut ptb, package_id)?; + let locking_config = locking_config.to_ptb(&mut ptb, audit_trail_package_id, tf_components_package_id)?; - let immutable_metadata_tag = ImmutableMetadata::tag(package_id); + let immutable_metadata_tag = ImmutableMetadata::tag(audit_trail_package_id); let trail_metadata = match trail_metadata { Some(metadata) => { - let metadata_arg = metadata.to_ptb(&mut ptb, package_id)?; + let metadata_arg = metadata.to_ptb(&mut ptb, audit_trail_package_id)?; utils::option_to_move(Some(metadata_arg), immutable_metadata_tag, &mut ptb) .map_err(|e| Error::InvalidArgument(format!("failed to build trail_metadata option: {e}")))? } @@ -51,7 +52,7 @@ impl CreateOps { let clock = utils::get_clock_ref(&mut ptb); let result = ptb.programmable_move_call( - package_id, + audit_trail_package_id, ident_str!("main").into(), ident_str!("create").into(), vec![data_tag], diff --git a/audit-trail-rs/src/core/create/transactions.rs b/audit-trail-rs/src/core/create/transactions.rs index 81f775d5..9b714353 100644 --- a/audit-trail-rs/src/core/create/transactions.rs +++ b/audit-trail-rs/src/core/create/transactions.rs @@ -2,12 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 use async_trait::async_trait; -use iota_interaction::rpc_types::{ - IotaData as _, IotaObjectDataOptions, IotaTransactionBlockEffects, IotaTransactionBlockEvents, -}; +use iota_interaction::OptionalSync; +use iota_interaction::rpc_types::{IotaTransactionBlockEffects, IotaTransactionBlockEvents}; use iota_interaction::types::base_types::ObjectID; use iota_interaction::types::transaction::ProgrammableTransaction; -use iota_interaction::{IotaClientTrait, OptionalSync}; use iota_sdk::types::base_types::IotaAddress; use product_common::core_client::CoreClientReadOnly; use product_common::transaction::transaction_builder::Transaction; @@ -18,6 +16,7 @@ use crate::core::builder::AuditTrailBuilder; use crate::core::operations; use crate::core::types::{AuditTrailCreated, Event, OnChainAuditTrail}; use crate::error::Error; +use crate::package; /// Output of a create trail transaction. #[derive(Debug, Clone)] @@ -71,9 +70,11 @@ impl CreateTrail { .to_string(), ) })?; + let tf_components_package_id = package::tf_components_package_id(); CreateOps::create_trail( client.package_id(), + tf_components_package_id, admin, data, record_metadata, diff --git a/audit-trail-rs/src/core/locking/mod.rs b/audit-trail-rs/src/core/locking/mod.rs index f967d676..9a26e9c3 100644 --- a/audit-trail-rs/src/core/locking/mod.rs +++ b/audit-trail-rs/src/core/locking/mod.rs @@ -8,13 +8,13 @@ use product_common::transaction::transaction_builder::TransactionBuilder; use secret_storage::Signer; use crate::core::trail::{AuditTrailFull, AuditTrailReadOnly}; -use crate::core::types::{LockingConfig, LockingWindow}; +use crate::core::types::{LockingConfig, LockingWindow, TimeLock}; use crate::error::Error; mod operations; mod transactions; -pub use transactions::{UpdateDeleteRecordWindow, UpdateLockingConfig}; +pub use transactions::{UpdateDeleteRecordWindow, UpdateDeleteTrailLock, UpdateLockingConfig, UpdateWriteLock}; use self::operations::LockingOps; @@ -47,6 +47,24 @@ impl<'a, C> TrailLocking<'a, C> { TransactionBuilder::new(UpdateDeleteRecordWindow::new(self.trail_id, owner, window)) } + pub fn update_delete_trail_lock(&self, lock: TimeLock) -> TransactionBuilder + where + C: AuditTrailFull + CoreClient, + S: Signer + OptionalSync, + { + let owner = self.client.sender_address(); + TransactionBuilder::new(UpdateDeleteTrailLock::new(self.trail_id, owner, lock)) + } + + pub fn update_write_lock(&self, lock: TimeLock) -> TransactionBuilder + where + C: AuditTrailFull + CoreClient, + S: Signer + OptionalSync, + { + let owner = self.client.sender_address(); + TransactionBuilder::new(UpdateWriteLock::new(self.trail_id, owner, lock)) + } + pub async fn is_record_locked(&self, sequence_number: u64) -> Result where C: AuditTrailReadOnly, diff --git a/audit-trail-rs/src/core/locking/operations.rs b/audit-trail-rs/src/core/locking/operations.rs index a33473f6..ae04ee8c 100644 --- a/audit-trail-rs/src/core/locking/operations.rs +++ b/audit-trail-rs/src/core/locking/operations.rs @@ -6,9 +6,10 @@ use iota_interaction::types::base_types::{IotaAddress, ObjectID}; use iota_interaction::types::transaction::ProgrammableTransaction; use product_common::core_client::CoreClientReadOnly; -use crate::core::types::{LockingConfig, LockingWindow, Permission}; +use crate::core::types::{LockingConfig, LockingWindow, Permission, TimeLock}; use crate::core::{operations, utils}; use crate::error::Error; +use crate::package; pub(super) struct LockingOps; @@ -22,6 +23,8 @@ impl LockingOps { where C: CoreClientReadOnly + OptionalSync, { + let tf_components_package_id = package::tf_components_package_id(); + operations::build_trail_transaction( client, trail_id, @@ -29,7 +32,7 @@ impl LockingOps { Permission::UpdateLockingConfig, "update_locking_config", |ptb, _| { - let config = new_config.to_ptb(ptb, client.package_id())?; + let config = new_config.to_ptb(ptb, client.package_id(), tf_components_package_id)?; let clock = utils::get_clock_ref(ptb); Ok(vec![config, clock]) @@ -42,7 +45,7 @@ impl LockingOps { client: &C, trail_id: ObjectID, owner: IotaAddress, - new_window: LockingWindow, + new_delete_record_window: LockingWindow, ) -> Result where C: CoreClientReadOnly + OptionalSync, @@ -52,9 +55,9 @@ impl LockingOps { trail_id, owner, Permission::UpdateLockingConfigForDeleteRecord, - "update_locking_config_for_delete_record", + "update_delete_record_window", |ptb, _| { - let window = new_window.to_ptb(ptb, client.package_id())?; + let window = new_delete_record_window.to_ptb(ptb, client.package_id())?; let clock = utils::get_clock_ref(ptb); Ok(vec![window, clock]) @@ -63,6 +66,60 @@ impl LockingOps { .await } + pub(super) async fn update_delete_trail_lock( + client: &C, + trail_id: ObjectID, + owner: IotaAddress, + new_delete_trail_lock: TimeLock, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + let tf_components_package_id = package::tf_components_package_id(); + + operations::build_trail_transaction( + client, + trail_id, + owner, + Permission::UpdateLockingConfigForDeleteTrail, + "update_delete_trail_lock", + |ptb, _| { + let delete_trail_lock = new_delete_trail_lock.to_ptb(ptb, tf_components_package_id)?; + let clock = utils::get_clock_ref(ptb); + + Ok(vec![delete_trail_lock, clock]) + }, + ) + .await + } + + pub(super) async fn update_write_lock( + client: &C, + trail_id: ObjectID, + owner: IotaAddress, + new_write_lock: TimeLock, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + let tf_components_package_id = package::tf_components_package_id(); + + operations::build_trail_transaction( + client, + trail_id, + owner, + Permission::UpdateLockingConfigForWrite, + "update_write_lock", + |ptb, _| { + let write_lock = new_write_lock.to_ptb(ptb, tf_components_package_id)?; + let clock = utils::get_clock_ref(ptb); + + Ok(vec![write_lock, clock]) + }, + ) + .await + } + pub(super) async fn is_record_locked( client: &C, trail_id: ObjectID, diff --git a/audit-trail-rs/src/core/locking/transactions.rs b/audit-trail-rs/src/core/locking/transactions.rs index c2a929b5..b3117d84 100644 --- a/audit-trail-rs/src/core/locking/transactions.rs +++ b/audit-trail-rs/src/core/locking/transactions.rs @@ -3,7 +3,7 @@ use async_trait::async_trait; use iota_interaction::OptionalSync; -use iota_interaction::rpc_types::{IotaTransactionBlockEffects, IotaTransactionBlockEvents}; +use iota_interaction::rpc_types::IotaTransactionBlockEffects; use iota_interaction::types::base_types::{IotaAddress, ObjectID}; use iota_interaction::types::transaction::ProgrammableTransaction; use product_common::core_client::CoreClientReadOnly; @@ -11,7 +11,7 @@ use product_common::transaction::transaction_builder::Transaction; use tokio::sync::OnceCell; use super::operations::LockingOps; -use crate::core::types::{LockingConfig, LockingWindow}; +use crate::core::types::{LockingConfig, LockingWindow, TimeLock}; use crate::error::Error; #[derive(Debug, Clone)] @@ -107,3 +107,97 @@ impl Transaction for UpdateDeleteRecordWindow { Ok(()) } } + +#[derive(Debug, Clone)] +pub struct UpdateDeleteTrailLock { + trail_id: ObjectID, + owner: IotaAddress, + lock: TimeLock, + cached_ptb: OnceCell, +} + +impl UpdateDeleteTrailLock { + pub fn new(trail_id: ObjectID, owner: IotaAddress, lock: TimeLock) -> Self { + Self { + trail_id, + owner, + lock, + cached_ptb: OnceCell::new(), + } + } + + async fn make_ptb(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + LockingOps::update_delete_trail_lock(client, self.trail_id, self.owner, self.lock.clone()).await + } +} + +#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] +#[cfg_attr(feature = "send-sync", async_trait)] +impl Transaction for UpdateDeleteTrailLock { + type Error = Error; + type Output = (); + + async fn build_programmable_transaction(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() + } + + async fn apply(self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + Ok(()) + } +} + +#[derive(Debug, Clone)] +pub struct UpdateWriteLock { + trail_id: ObjectID, + owner: IotaAddress, + lock: TimeLock, + cached_ptb: OnceCell, +} + +impl UpdateWriteLock { + pub fn new(trail_id: ObjectID, owner: IotaAddress, lock: TimeLock) -> Self { + Self { + trail_id, + owner, + lock, + cached_ptb: OnceCell::new(), + } + } + + async fn make_ptb(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + LockingOps::update_write_lock(client, self.trail_id, self.owner, self.lock.clone()).await + } +} + +#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] +#[cfg_attr(feature = "send-sync", async_trait)] +impl Transaction for UpdateWriteLock { + type Error = Error; + type Output = (); + + async fn build_programmable_transaction(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() + } + + async fn apply(self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + Ok(()) + } +} diff --git a/audit-trail-rs/src/core/types/locking.rs b/audit-trail-rs/src/core/types/locking.rs index 1daea35d..845d65c3 100644 --- a/audit-trail-rs/src/core/types/locking.rs +++ b/audit-trail-rs/src/core/types/locking.rs @@ -13,26 +13,100 @@ use crate::error::Error; /// Locking configuration for the audit trail. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] pub struct LockingConfig { - pub delete_record: LockingWindow, + pub delete_record_window: LockingWindow, + pub delete_trail_lock: TimeLock, + pub write_lock: TimeLock, } impl LockingConfig { /// Creates a new `Argument` from the `LockingConfig`. /// /// To be used when creating or updating locking config on the ledger. - pub(in crate::core) fn to_ptb(&self, ptb: &mut Ptb, package_id: ObjectID) -> Result { - let delete_record_lock = self.delete_record.to_ptb(ptb, package_id)?; + pub(in crate::core) fn to_ptb( + &self, + ptb: &mut Ptb, + package_id: ObjectID, + tf_components_package_id: ObjectID, + ) -> Result { + let delete_record_window = self.delete_record_window.to_ptb(ptb, package_id)?; + let delete_trail_lock = self.delete_trail_lock.to_ptb(ptb, tf_components_package_id)?; + let write_lock = self.write_lock.to_ptb(ptb, tf_components_package_id)?; Ok(ptb.programmable_move_call( package_id, ident_str!("locking").into(), ident_str!("new").into(), vec![], - vec![delete_record_lock], + vec![delete_record_window, delete_trail_lock, write_lock], )) } } +/// Time-based lock for trail-level operations. +/// +/// Must match `tf_components::timelock::TimeLock` variant order for BCS compatibility. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub enum TimeLock { + UnlockAt(u32), + UnlockAtMs(u64), + UntilDestroyed, + Infinite, + #[default] + None, +} + +impl TimeLock { + pub(in crate::core) fn to_ptb(&self, ptb: &mut Ptb, package_id: ObjectID) -> Result { + match self { + Self::None => Ok(ptb.programmable_move_call( + package_id, + ident_str!("timelock").into(), + ident_str!("none").into(), + vec![], + vec![], + )), + Self::Infinite => Ok(ptb.programmable_move_call( + package_id, + ident_str!("timelock").into(), + ident_str!("infinite").into(), + vec![], + vec![], + )), + Self::UntilDestroyed => Ok(ptb.programmable_move_call( + package_id, + ident_str!("timelock").into(), + ident_str!("until_destroyed").into(), + vec![], + vec![], + )), + Self::UnlockAt(unix_time) => { + let unix_time = utils::ptb_pure(ptb, "unix_time", *unix_time)?; + let clock = utils::get_clock_ref(ptb); + + Ok(ptb.programmable_move_call( + package_id, + ident_str!("timelock").into(), + ident_str!("unlock_at").into(), + vec![], + vec![unix_time, clock], + )) + } + Self::UnlockAtMs(unix_time_ms) => { + let unix_time_ms = utils::ptb_pure(ptb, "unix_time_ms", *unix_time_ms)?; + let clock = utils::get_clock_ref(ptb); + + Ok(ptb.programmable_move_call( + package_id, + ident_str!("timelock").into(), + ident_str!("unlock_at_ms").into(), + vec![], + vec![unix_time_ms, clock], + )) + } + } + } +} + /// Defines a locking window (none, time based, or count based). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] pub enum LockingWindow { diff --git a/audit-trail-rs/src/core/types/permission.rs b/audit-trail-rs/src/core/types/permission.rs index 8f934b79..8996b398 100644 --- a/audit-trail-rs/src/core/types/permission.rs +++ b/audit-trail-rs/src/core/types/permission.rs @@ -24,6 +24,7 @@ pub enum Permission { UpdateLockingConfig, UpdateLockingConfigForDeleteRecord, UpdateLockingConfigForDeleteTrail, + UpdateLockingConfigForWrite, AddRoles, UpdateRoles, DeleteRoles, @@ -46,6 +47,7 @@ impl Permission { Self::UpdateLockingConfig => "update_locking_config", Self::UpdateLockingConfigForDeleteRecord => "update_locking_config_for_delete_record", Self::UpdateLockingConfigForDeleteTrail => "update_locking_config_for_delete_trail", + Self::UpdateLockingConfigForWrite => "update_locking_config_for_write", Self::AddRoles => "add_roles", Self::UpdateRoles => "update_roles", Self::DeleteRoles => "delete_roles", @@ -114,6 +116,7 @@ impl PermissionSet { Permission::UpdateLockingConfig, Permission::UpdateLockingConfigForDeleteTrail, Permission::UpdateLockingConfigForDeleteRecord, + Permission::UpdateLockingConfigForWrite, ]), } } diff --git a/audit-trail-rs/src/core/types/role_map.rs b/audit-trail-rs/src/core/types/role_map.rs index cf8e6f9d..7de465da 100644 --- a/audit-trail-rs/src/core/types/role_map.rs +++ b/audit-trail-rs/src/core/types/role_map.rs @@ -12,6 +12,7 @@ use serde::{Deserialize, Serialize}; use super::permission::Permission; use crate::core::utils::{deserialize_vec_map, deserialize_vec_set}; +use crate::package; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RoleMap { @@ -62,7 +63,9 @@ pub struct Capability { } impl MoveType for Capability { - fn move_type(package: ObjectID) -> TypeTag { - TypeTag::from_str(format!("{package}::capability::Capability").as_str()).expect("failed to create type tag") + fn move_type(_: ObjectID) -> TypeTag { + let tf_components_package_id = package::tf_components_package_id(); + TypeTag::from_str(format!("{tf_components_package_id}::capability::Capability").as_str()) + .expect("failed to create type tag") } } diff --git a/audit-trail-rs/src/package.rs b/audit-trail-rs/src/package.rs index ce800ff8..65507534 100644 --- a/audit-trail-rs/src/package.rs +++ b/audit-trail-rs/src/package.rs @@ -8,8 +8,10 @@ #![allow(dead_code)] +use std::str::FromStr; use std::sync::LazyLock; +use iota_interaction::types::base_types::ObjectID; use product_common::package_registry::PackageRegistry; use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard, TryLockError}; @@ -28,6 +30,11 @@ static AUDIT_TRAIL_PACKAGE_REGISTRY: LazyLock> = LazyLoc ) }); +/// Hardcoded TfComponents package ID used for timelock constructors. +/// +/// Update this value after publishing TfComponents. +const TF_COMPONENTS_PACKAGE_ID: &str = "0x3ad97a0004b116fde2bc8e183a64b79581a3248d995533a898717b689ad43a2c"; + /// Returns a read lock to the package registry. pub(crate) async fn audit_trail_package_registry() -> PackageRegistryLock { AUDIT_TRAIL_PACKAGE_REGISTRY.read().await @@ -57,3 +64,7 @@ pub(crate) fn try_audit_trail_package_registry_mut() -> Result PackageRegistryLockMut { AUDIT_TRAIL_PACKAGE_REGISTRY.blocking_write() } + +pub(crate) fn tf_components_package_id() -> ObjectID { + ObjectID::from_str(TF_COMPONENTS_PACKAGE_ID).expect("`TF_COMPONENTS_PACKAGE_ID` must be a valid ObjectID") +} diff --git a/audit-trail-rs/tests/e2e/locking.rs b/audit-trail-rs/tests/e2e/locking.rs index 056fca34..20112177 100644 --- a/audit-trail-rs/tests/e2e/locking.rs +++ b/audit-trail-rs/tests/e2e/locking.rs @@ -1,7 +1,7 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use audit_trails::core::types::{CapabilityIssueOptions, Data, LockingConfig, LockingWindow, Permission}; +use audit_trails::core::types::{CapabilityIssueOptions, Data, LockingConfig, LockingWindow, Permission, TimeLock}; use iota_interaction::types::base_types::ObjectID; use crate::client::{TestClient, get_funded_test_client}; @@ -19,6 +19,14 @@ async fn grant_role_capability( Ok(()) } +fn config_with_window(delete_record_window: LockingWindow) -> LockingConfig { + LockingConfig { + delete_record_window, + delete_trail_lock: TimeLock::None, + write_lock: TimeLock::None, + } +} + #[tokio::test] async fn update_locking_config_roundtrip() -> anyhow::Result<()> { let client = get_funded_test_client().await?; @@ -29,18 +37,14 @@ async fn update_locking_config_roundtrip() -> anyhow::Result<()> { trail .locking() - .update(LockingConfig { - delete_record: LockingWindow::CountBased { count: 2 }, - }) + .update(config_with_window(LockingWindow::CountBased { count: 2 })) .build_and_execute(&client) .await?; let on_chain = trail.get().await?; assert_eq!( on_chain.locking_config, - LockingConfig { - delete_record: LockingWindow::CountBased { count: 2 } - } + config_with_window(LockingWindow::CountBased { count: 2 }) ); Ok(()) @@ -52,9 +56,7 @@ async fn update_locking_config_switches_count_to_time_based() -> anyhow::Result< let trail_id = client .create_trail() .with_initial_record(Data::text("trail-switch-count-to-time-e2e"), None) - .with_locking_config(LockingConfig { - delete_record: LockingWindow::CountBased { count: 3 }, - }) + .with_locking_config(config_with_window(LockingWindow::CountBased { count: 3 })) .finish() .build_and_execute(&client) .await? @@ -67,25 +69,19 @@ async fn update_locking_config_switches_count_to_time_based() -> anyhow::Result< let before = trail.get().await?; assert_eq!( before.locking_config, - LockingConfig { - delete_record: LockingWindow::CountBased { count: 3 } - } + config_with_window(LockingWindow::CountBased { count: 3 }) ); trail .locking() - .update(LockingConfig { - delete_record: LockingWindow::TimeBased { seconds: 300 }, - }) + .update(config_with_window(LockingWindow::TimeBased { seconds: 300 })) .build_and_execute(&client) .await?; let after = trail.get().await?; assert_eq!( after.locking_config, - LockingConfig { - delete_record: LockingWindow::TimeBased { seconds: 300 } - } + config_with_window(LockingWindow::TimeBased { seconds: 300 }) ); Ok(()) @@ -113,17 +109,92 @@ async fn update_delete_record_window_roundtrip() -> anyhow::Result<()> { .build_and_execute(&client) .await?; + let on_chain = trail.get().await?; + assert_eq!( + on_chain.locking_config, + config_with_window(LockingWindow::TimeBased { seconds: 120 }) + ); + + Ok(()) +} + +#[tokio::test] +async fn update_delete_trail_lock_roundtrip() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client + .create_test_trail(Data::text("trail-update-delete-trail-lock-e2e")) + .await?; + let trail = client.trail(trail_id); + + grant_role_capability( + &client, + trail_id, + "DeleteTrailLockAdmin", + [Permission::UpdateLockingConfigForDeleteTrail], + ) + .await?; + + trail + .locking() + .update_delete_trail_lock(TimeLock::Infinite) + .build_and_execute(&client) + .await?; + let on_chain = trail.get().await?; assert_eq!( on_chain.locking_config, LockingConfig { - delete_record: LockingWindow::TimeBased { seconds: 120 } + delete_record_window: LockingWindow::None, + delete_trail_lock: TimeLock::Infinite, + write_lock: TimeLock::None, } ); Ok(()) } +#[tokio::test] +async fn update_write_lock_roundtrip_and_blocks_add_record() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client + .create_test_trail(Data::text("trail-update-write-lock-e2e")) + .await?; + let trail = client.trail(trail_id); + + grant_role_capability( + &client, + trail_id, + "WriteLockAdmin", + [Permission::UpdateLockingConfigForWrite, Permission::AddRecord], + ) + .await?; + + trail + .locking() + .update_write_lock(TimeLock::Infinite) + .build_and_execute(&client) + .await?; + + let on_chain = trail.get().await?; + assert_eq!( + on_chain.locking_config, + LockingConfig { + delete_record_window: LockingWindow::None, + delete_trail_lock: TimeLock::None, + write_lock: TimeLock::Infinite, + } + ); + + let add_locked = trail + .records() + .add(Data::text("should-fail-write-locked"), None) + .build_and_execute(&client) + .await; + assert!(add_locked.is_err(), "write lock should block adding new records"); + + Ok(()) +} + #[tokio::test] async fn update_locking_config_requires_permission() -> anyhow::Result<()> { let client = get_funded_test_client().await?; @@ -134,9 +205,7 @@ async fn update_locking_config_requires_permission() -> anyhow::Result<()> { let result = client .trail(trail_id) .locking() - .update(LockingConfig { - delete_record: LockingWindow::TimeBased { seconds: 60 }, - }) + .update(config_with_window(LockingWindow::TimeBased { seconds: 60 })) .build_and_execute(&client) .await; @@ -148,15 +217,35 @@ async fn update_locking_config_requires_permission() -> anyhow::Result<()> { Ok(()) } +#[tokio::test] +async fn update_write_lock_requires_permission() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client + .create_test_trail(Data::text("trail-write-lock-permission-e2e")) + .await?; + + let update_result = client + .trail(trail_id) + .locking() + .update_write_lock(TimeLock::Infinite) + .build_and_execute(&client) + .await; + + assert!( + update_result.is_err(), + "updating write lock without UpdateLockingConfigForWrite permission must fail" + ); + + Ok(()) +} + #[tokio::test] async fn is_record_locked_supports_count_window_and_missing_sequence() -> anyhow::Result<()> { let client = get_funded_test_client().await?; let trail_id = client .create_trail() .with_initial_record(Data::text("trail-locking-status-e2e"), None) - .with_locking_config(LockingConfig { - delete_record: LockingWindow::CountBased { count: 2 }, - }) + .with_locking_config(config_with_window(LockingWindow::CountBased { count: 2 })) .finish() .build_and_execute(&client) .await? @@ -217,9 +306,7 @@ async fn delete_window_variants_roundtrip() -> anyhow::Result<()> { let on_chain = trail.get().await?; assert_eq!( on_chain.locking_config, - LockingConfig { - delete_record: LockingWindow::TimeBased { seconds: 3600 } - } + config_with_window(LockingWindow::TimeBased { seconds: 3600 }) ); trail @@ -231,9 +318,7 @@ async fn delete_window_variants_roundtrip() -> anyhow::Result<()> { let on_chain = trail.get().await?; assert_eq!( on_chain.locking_config, - LockingConfig { - delete_record: LockingWindow::CountBased { count: 1 } - } + config_with_window(LockingWindow::CountBased { count: 1 }) ); trail @@ -243,12 +328,7 @@ async fn delete_window_variants_roundtrip() -> anyhow::Result<()> { .await?; let on_chain = trail.get().await?; - assert_eq!( - on_chain.locking_config, - LockingConfig { - delete_record: LockingWindow::None - } - ); + assert_eq!(on_chain.locking_config, config_with_window(LockingWindow::None)); Ok(()) } @@ -281,9 +361,7 @@ async fn updated_time_lock_blocks_record_deletion() -> anyhow::Result<()> { trail .locking() - .update(LockingConfig { - delete_record: LockingWindow::TimeBased { seconds: 3600 }, - }) + .update(config_with_window(LockingWindow::TimeBased { seconds: 3600 })) .build_and_execute(&client) .await?; diff --git a/audit-trail-rs/tests/e2e/records.rs b/audit-trail-rs/tests/e2e/records.rs index c507e83a..29be4dfe 100644 --- a/audit-trail-rs/tests/e2e/records.rs +++ b/audit-trail-rs/tests/e2e/records.rs @@ -1,7 +1,7 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use audit_trails::core::types::{CapabilityIssueOptions, Data, LockingConfig, LockingWindow, Permission}; +use audit_trails::core::types::{CapabilityIssueOptions, Data, LockingConfig, LockingWindow, Permission, TimeLock}; use audit_trails::error::Error; use iota_interaction::types::base_types::ObjectID; use product_common::core_client::CoreClient; @@ -28,6 +28,14 @@ fn assert_text_data(data: Data, expected: &str) { } } +fn config_with_window(delete_record_window: LockingWindow) -> LockingConfig { + LockingConfig { + delete_record_window, + delete_trail_lock: TimeLock::None, + write_lock: TimeLock::None, + } +} + #[tokio::test] async fn add_and_fetch_record_roundtrip() -> anyhow::Result<()> { let client = get_funded_test_client().await?; @@ -174,9 +182,7 @@ async fn delete_record_fails_while_time_locked() -> anyhow::Result<()> { let created = client .create_trail() .with_initial_record(Data::text("locked"), None) - .with_locking_config(LockingConfig { - delete_record: LockingWindow::TimeBased { seconds: 3600 }, - }) + .with_locking_config(config_with_window(LockingWindow::TimeBased { seconds: 3600 })) .finish() .build_and_execute(&client) .await? @@ -239,9 +245,7 @@ async fn delete_record_fails_while_count_locked() -> anyhow::Result<()> { let created = client .create_trail() .with_initial_record(Data::text("count-locked"), None) - .with_locking_config(LockingConfig { - delete_record: LockingWindow::CountBased { count: 5 }, - }) + .with_locking_config(config_with_window(LockingWindow::CountBased { count: 5 })) .finish() .build_and_execute(&client) .await? @@ -264,9 +268,7 @@ async fn delete_records_batch_respects_limit_and_deletes_oldest_first() -> anyho let created = client .create_trail() .with_initial_record(Data::text("batch-initial"), None) - .with_locking_config(LockingConfig { - delete_record: LockingWindow::TimeBased { seconds: 3600 }, - }) + .with_locking_config(config_with_window(LockingWindow::TimeBased { seconds: 3600 })) .finish() .build_and_execute(&client) .await? diff --git a/audit-trail-rs/tests/e2e/trail.rs b/audit-trail-rs/tests/e2e/trail.rs index c61712b7..d84a6448 100644 --- a/audit-trail-rs/tests/e2e/trail.rs +++ b/audit-trail-rs/tests/e2e/trail.rs @@ -2,13 +2,21 @@ // SPDX-License-Identifier: Apache-2.0 use audit_trails::core::types::{ - CapabilityIssueOptions, Data, ImmutableMetadata, LockingConfig, LockingWindow, Permission, + CapabilityIssueOptions, Data, ImmutableMetadata, LockingConfig, LockingWindow, Permission, TimeLock, }; use iota_interaction::types::base_types::IotaAddress; use product_common::core_client::CoreClient; use crate::client::get_funded_test_client; +fn config_with_window(delete_record_window: LockingWindow) -> LockingConfig { + LockingConfig { + delete_record_window, + delete_trail_lock: TimeLock::None, + write_lock: TimeLock::None, + } +} + #[tokio::test] async fn create_trail_with_default_builder_settings() -> anyhow::Result<()> { let client = get_funded_test_client().await?; @@ -27,12 +35,7 @@ async fn create_trail_with_default_builder_settings() -> anyhow::Result<()> { assert_eq!(on_chain.id.object_id(), &created.trail_id); assert_eq!(on_chain.creator, client.sender_address()); assert_eq!(on_chain.sequence_number, 1); - assert_eq!( - on_chain.locking_config, - LockingConfig { - delete_record: LockingWindow::None - } - ); + assert_eq!(on_chain.locking_config, config_with_window(LockingWindow::None)); assert!(on_chain.immutable_metadata.is_none()); assert!(on_chain.updatable_metadata.is_none()); @@ -52,9 +55,7 @@ async fn create_trail_with_metadata_and_time_lock() -> anyhow::Result<()> { Data::text("audit-trail-create-time-lock"), Some("initial record metadata".to_string()), ) - .with_locking_config(LockingConfig { - delete_record: LockingWindow::TimeBased { seconds: 300 }, - }) + .with_locking_config(config_with_window(LockingWindow::TimeBased { seconds: 300 })) .with_trail_metadata(immutable_metadata.clone()) .with_updatable_metadata("updatable metadata") .finish() @@ -65,9 +66,7 @@ async fn create_trail_with_metadata_and_time_lock() -> anyhow::Result<()> { let on_chain = created.fetch_audit_trail(&client).await?; assert_eq!( on_chain.locking_config, - LockingConfig { - delete_record: LockingWindow::TimeBased { seconds: 300 } - } + config_with_window(LockingWindow::TimeBased { seconds: 300 }) ); assert_eq!(on_chain.immutable_metadata, Some(immutable_metadata)); assert_eq!(on_chain.updatable_metadata, Some("updatable metadata".to_string())); @@ -85,9 +84,7 @@ async fn create_trail_with_bytes_and_count_lock() -> anyhow::Result<()> { Data::bytes(vec![0xAA, 0xBB, 0xCC, 0xDD]), Some("bytes metadata".to_string()), ) - .with_locking_config(LockingConfig { - delete_record: LockingWindow::CountBased { count: 3 }, - }) + .with_locking_config(config_with_window(LockingWindow::CountBased { count: 3 })) .with_trail_metadata_parts("Trail Count Lock", Some("count lock description".to_string())) .finish() .build_and_execute(&client) @@ -97,9 +94,7 @@ async fn create_trail_with_bytes_and_count_lock() -> anyhow::Result<()> { let on_chain = created.fetch_audit_trail(&client).await?; assert_eq!( on_chain.locking_config, - LockingConfig { - delete_record: LockingWindow::CountBased { count: 3 } - } + config_with_window(LockingWindow::CountBased { count: 3 }) ); assert_eq!(on_chain.sequence_number, 1); @@ -358,9 +353,7 @@ async fn delete_records_batch_then_delete_audit_trail_roundtrip() -> anyhow::Res let created = client .create_trail() .with_initial_record(Data::text("trail-batch-delete-e2e"), None) - .with_locking_config(LockingConfig { - delete_record: LockingWindow::TimeBased { seconds: 3600 }, - }) + .with_locking_config(config_with_window(LockingWindow::TimeBased { seconds: 3600 })) .finish() .build_and_execute(&client) .await? From 0bda0c0c4a59806489f85c8bdb03179d19c57e27 Mon Sep 17 00:00:00 2001 From: Yasir Date: Tue, 3 Mar 2026 14:16:28 +0300 Subject: [PATCH 065/189] feat: Refactor capability validation method to assert_capability_valid --- audit-trail-move/sources/audit_trail.move | 24 +++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/audit-trail-move/sources/audit_trail.move b/audit-trail-move/sources/audit_trail.move index 3ea14fe7..8ac4c84a 100644 --- a/audit-trail-move/sources/audit_trail.move +++ b/audit-trail-move/sources/audit_trail.move @@ -251,12 +251,12 @@ entry fun migrate( trail: &mut AuditTrail, cap: &Capability, clock: &Clock, - ctx: &mut TxContext, + ctx: &TxContext, ) { assert!(trail.version < PACKAGE_VERSION, EPackageVersionMismatch); trail .roles - .is_capability_valid( + .assert_capability_valid( cap, &permission::migrate_audit_trail(), clock, @@ -281,7 +281,7 @@ public fun add_record( assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); trail .roles - .is_capability_valid( + .assert_capability_valid( cap, &permission::add_record(), clock, @@ -328,7 +328,7 @@ public fun delete_record( assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); trail .roles - .is_capability_valid( + .assert_capability_valid( cap, &permission::delete_record(), clock, @@ -366,7 +366,7 @@ public fun delete_records_batch( assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); trail .roles - .is_capability_valid( + .assert_capability_valid( cap, &permission::delete_all_records(), clock, @@ -408,7 +408,7 @@ public fun delete_audit_trail( assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); trail .roles - .is_capability_valid( + .assert_capability_valid( cap, &permission::delete_audit_trail(), clock, @@ -473,7 +473,7 @@ public fun update_locking_config( assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); trail .roles - .is_capability_valid( + .assert_capability_valid( cap, &permission::update_locking_config(), clock, @@ -493,7 +493,7 @@ public fun update_delete_record_window( assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); trail .roles - .is_capability_valid( + .assert_capability_valid( cap, &permission::update_locking_config_for_delete_record(), clock, @@ -513,7 +513,7 @@ public fun update_delete_trail_lock( assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); trail .roles - .is_capability_valid( + .assert_capability_valid( cap, &permission::update_locking_config_for_delete_trail(), clock, @@ -533,7 +533,7 @@ public fun update_write_lock( assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); trail .roles - .is_capability_valid( + .assert_capability_valid( cap, &permission::update_locking_config_for_write(), clock, @@ -553,7 +553,7 @@ public fun update_metadata( assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); trail .roles - .is_capability_valid( + .assert_capability_valid( cap, &permission::update_metadata(), clock, @@ -682,7 +682,7 @@ public fun destroy_capability( assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); trail .roles - .is_capability_valid( + .assert_capability_valid( cap, &permission::revoke_capabilities(), clock, From 0b9266011344d4febd1d075ad6173d9cda1a5a1a Mon Sep 17 00:00:00 2001 From: Yasir Date: Tue, 3 Mar 2026 16:57:24 +0300 Subject: [PATCH 066/189] Update dependencies and refactor event handling in audit trail - Updated `Cargo.toml` to pin `iota_interaction`, `iota_interaction_rust`, `iota_interaction_ts`, and `product_common` to the `feat/tf-compoenents-dev` branch. - Updated `Move.lock` to use the new chain ID and published IDs. - Removed role-related event structs (`RoleCreated`, `RoleUpdated`, `RoleDeleted`) from `audit_trail.move` and replaced them with a single `RoleRemoved` struct. - Refactored transaction handling in `transactions.rs` to accommodate the new `RoleRemoved` event. - Updated event handling in `event.rs` to reflect the changes in role event structures. - Adjusted tests across multiple files to ensure compatibility with the new role event structure and locking configurations. - Updated package ID for `TF_COMPONENTS_PACKAGE_ID` in `package.rs`. --- Cargo.toml | 8 +- audit-trail-move/Move.lock | 8 +- audit-trail-move/sources/audit_trail.move | 46 ------- audit-trail-move/sources/locking.move | 5 +- audit-trail-move/tests/capability_tests.move | 36 +++++- .../tests/create_audit_trail_tests.move | 42 +++++-- audit-trail-move/tests/locking_tests.move | 114 +++++++++++++++--- audit-trail-move/tests/metadata_tests.move | 18 ++- audit-trail-move/tests/record_tests.move | 72 +++++++++-- audit-trail-move/tests/role_tests.move | 48 ++++++-- audit-trail-rs/src/core/roles/transactions.rs | 16 +-- audit-trail-rs/src/core/types/event.rs | 16 +-- audit-trail-rs/src/package.rs | 2 +- audit-trail-rs/tests/e2e/roles.rs | 9 -- bindings/wasm/notarization_wasm/Cargo.toml | 6 +- 15 files changed, 298 insertions(+), 148 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index aebffd63..1747557b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,10 +19,10 @@ chrono = { version = "0.4", default-features = false } # Latest hyper is not compatible with axum-server used by iota-sdk. We need to pin it to 1.7 until iota-sdk upgrades axum-server. hyper = "=1.7" # Fix for iota-sdk 1.13 issue with axum-server. iota-sdk = { git = "https://github.com/iotaledger/iota.git", package = "iota-sdk", tag = "v1.16.2" } -iota_interaction = { git = "https://github.com/iotaledger/product-core.git", branch = "feat/emit-events-for-capabilities", default-features = false, package = "iota_interaction" } -iota_interaction_rust = { git = "https://github.com/iotaledger/product-core.git", branch = "feat/emit-events-for-capabilities", default-features = false, package = "iota_interaction_rust" } -iota_interaction_ts = { git = "https://github.com/iotaledger/product-core.git", branch = "feat/emit-events-for-capabilities", default-features = false, package = "iota_interaction_ts" } -product_common = { git = "https://github.com/iotaledger/product-core.git", branch = "feat/emit-events-for-capabilities", default-features = false, package = "product_common" } +iota_interaction = { git = "https://github.com/iotaledger/product-core.git", branch = "feat/tf-compoenents-dev", default-features = false, package = "iota_interaction" } +iota_interaction_rust = { git = "https://github.com/iotaledger/product-core.git", branch = "feat/tf-compoenents-dev", default-features = false, package = "iota_interaction_rust" } +iota_interaction_ts = { git = "https://github.com/iotaledger/product-core.git", branch = "feat/tf-compoenents-dev", default-features = false, package = "iota_interaction_ts" } +product_common = { git = "https://github.com/iotaledger/product-core.git", branch = "feat/tf-compoenents-dev", default-features = false, package = "product_common" } secret-storage = { git = "https://github.com/iotaledger/secret-storage.git", tag = "v0.3.0", default-features = false } serde = { version = "1.0", default-features = false, features = ["alloc", "derive"] } serde-aux = { version = "4.7.0", default-features = false } diff --git a/audit-trail-move/Move.lock b/audit-trail-move/Move.lock index 67d63353..c75ab983 100644 --- a/audit-trail-move/Move.lock +++ b/audit-trail-move/Move.lock @@ -54,14 +54,14 @@ dependencies = [ ] [move.toolchain-version] -compiler-version = "1.17.1-rc" +compiler-version = "1.17.2" edition = "2024.beta" flavor = "iota" [env] [env.localnet] -chain-id = "372ade72" -original-published-id = "0xf6c9b7930b9a5ca728ad49493551fb128cbc54892d7a464b73720d20f2be2d46" -latest-published-id = "0xf6c9b7930b9a5ca728ad49493551fb128cbc54892d7a464b73720d20f2be2d46" +chain-id = "426effa0" +original-published-id = "0x4644007b931d759798353f3a1f631f690658f04e3111d8438d211033d35fdd34" +latest-published-id = "0x4644007b931d759798353f3a1f631f690658f04e3111d8438d211033d35fdd34" published-version = "1" diff --git a/audit-trail-move/sources/audit_trail.move b/audit-trail-move/sources/audit_trail.move index 8ac4c84a..30432441 100644 --- a/audit-trail-move/sources/audit_trail.move +++ b/audit-trail-move/sources/audit_trail.move @@ -105,32 +105,6 @@ public struct RecordDeleted has copy, drop { timestamp: u64, } -/// Emitted when a role is created -public struct RoleCreated has copy, drop { - trail_id: ID, - role: String, - permissions: VecSet, - created_by: address, - timestamp: u64, -} - -/// Emitted when a role's permissions are updated -public struct RoleUpdated has copy, drop { - trail_id: ID, - role: String, - new_permissions: VecSet, - updated_by: address, - timestamp: u64, -} - -/// Emitted when a role is deleted -public struct RoleDeleted has copy, drop { - trail_id: ID, - role: String, - deleted_by: address, - timestamp: u64, -} - // ===== Constructors ===== /// Create immutable trail metadata @@ -575,13 +549,6 @@ public fun create_role( ) { assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); role_map::create_role(trail.roles_mut(), cap, role, permissions, clock, ctx); - event::emit(RoleCreated { - trail_id: trail.id(), - role, - permissions, - created_by: ctx.sender(), - timestamp: clock::timestamp_ms(clock), - }); } /// Updates permissions for an existing role. @@ -595,13 +562,6 @@ public fun update_role_permissions( ) { assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); role_map::update_role_permissions(trail.roles_mut(), cap, &role, new_permissions, clock, ctx); - event::emit(RoleUpdated { - trail_id: trail.id(), - role, - new_permissions, - updated_by: ctx.sender(), - timestamp: clock::timestamp_ms(clock), - }); } /// Deletes an existing role. @@ -614,12 +574,6 @@ public fun delete_role( ) { assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); role_map::delete_role(trail.roles_mut(), cap, &role, clock, ctx); - event::emit(RoleDeleted { - trail_id: trail.id(), - role, - deleted_by: ctx.sender(), - timestamp: clock::timestamp_ms(clock), - }); } /// Issues a new capability for an existing role. diff --git a/audit-trail-move/sources/locking.move b/audit-trail-move/sources/locking.move index da1d2804..0ff838f5 100644 --- a/audit-trail-move/sources/locking.move +++ b/audit-trail-move/sources/locking.move @@ -110,10 +110,7 @@ public(package) fun set_delete_record_window(config: &mut LockingConfig, window: /// Set the trail deletion timelock. public(package) fun set_delete_trail_lock(config: &mut LockingConfig, lock: TimeLock) { - assert!( - !timelock::is_until_destroyed(&lock), - EUntilDestroyedNotSupportedForDeleteTrail, - ); + assert!(!timelock::is_until_destroyed(&lock), EUntilDestroyedNotSupportedForDeleteTrail); config.delete_trail_lock = lock; } diff --git a/audit-trail-move/tests/capability_tests.move b/audit-trail-move/tests/capability_tests.move index bd01ab1a..6f28cc80 100644 --- a/audit-trail-move/tests/capability_tests.move +++ b/audit-trail-move/tests/capability_tests.move @@ -65,7 +65,11 @@ fun setup_trail_with_record_admin_capability_and_time_window_restriction( fun setup_trail_with_record_admin_role(scenario: &mut Scenario, admin_user: address): ID { // Setup: Create audit trail with admin capability let trail_id = { - let locking_config = locking::new(locking::window_count_based(0), timelock::none(), timelock::none()); + let locking_config = locking::new( + locking::window_count_based(0), + timelock::none(), + timelock::none(), + ); let (admin_cap, trail_id) = setup_test_audit_trail( scenario, @@ -112,7 +116,11 @@ fun test_new_capability() { let mut scenario = ts::begin(admin_user); let trail_id = { - let locking_config = locking::new(locking::window_count_based(0), timelock::none(), timelock::none()); + let locking_config = locking::new( + locking::window_count_based(0), + timelock::none(), + timelock::none(), + ); let (admin_cap, trail_id) = setup_test_audit_trail( &mut scenario, locking_config, @@ -634,7 +642,11 @@ fun test_revoked_capability_cannot_be_used() { let mut scenario = ts::begin(admin_user); { - let locking_config = locking::new(locking::window_count_based(0), timelock::none(), timelock::none()); + let locking_config = locking::new( + locking::window_count_based(0), + timelock::none(), + timelock::none(), + ); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -713,7 +725,11 @@ fun test_new_capability_for_nonexistent_role() { let mut scenario = ts::begin(admin_user); { - let locking_config = locking::new(locking::window_count_based(0), timelock::none(), timelock::none()); + let locking_config = locking::new( + locking::window_count_based(0), + timelock::none(), + timelock::none(), + ); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -751,7 +767,11 @@ fun test_revoke_capability_permission_denied() { let mut scenario = ts::begin(admin_user); { - let locking_config = locking::new(locking::window_count_based(0), timelock::none(), timelock::none()); + let locking_config = locking::new( + locking::window_count_based(0), + timelock::none(), + timelock::none(), + ); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -837,7 +857,11 @@ fun test_new_capability_permission_denied() { let mut scenario = ts::begin(admin_user); { - let locking_config = locking::new(locking::window_count_based(0), timelock::none(), timelock::none()); + let locking_config = locking::new( + locking::window_count_based(0), + timelock::none(), + timelock::none(), + ); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, diff --git a/audit-trail-move/tests/create_audit_trail_tests.move b/audit-trail-move/tests/create_audit_trail_tests.move index f1f4b5e7..9d39b1f4 100644 --- a/audit-trail-move/tests/create_audit_trail_tests.move +++ b/audit-trail-move/tests/create_audit_trail_tests.move @@ -24,7 +24,11 @@ fun test_create_without_initial_record() { let mut scenario = ts::begin(user); { - let locking_config = locking::new(locking::window_count_based(0), timelock::none(), timelock::none()); + let locking_config = locking::new( + locking::window_count_based(0), + timelock::none(), + timelock::none(), + ); let (admin_cap, trail_id) = setup_test_audit_trail( &mut scenario, @@ -61,7 +65,11 @@ fun test_create_with_initial_record() { let mut scenario = ts::begin(user); { - let locking_config = locking::new(locking::window_time_based(86400), timelock::none(), timelock::none()); // 1 day in seconds + let locking_config = locking::new( + locking::window_time_based(86400), + timelock::none(), + timelock::none(), + ); // 1 day in seconds let initial_data = new_test_data(42, b"Hello, World!"); let (admin_cap, trail_id) = setup_test_audit_trail( @@ -105,7 +113,11 @@ fun test_create_minimal_metadata() { let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); clock.set_for_testing(3000); - let locking_config = locking::new(locking::window_count_based(0), timelock::none(), timelock::none()); + let locking_config = locking::new( + locking::window_count_based(0), + timelock::none(), + timelock::none(), + ); let (admin_cap, _trail_id) = main::create( option::none(), @@ -146,7 +158,11 @@ fun test_create_with_locking_enabled() { let mut scenario = ts::begin(user); { - let locking_config = locking::new(locking::window_time_based(604800), timelock::none(), timelock::none()); // 7 days in seconds + let locking_config = locking::new( + locking::window_time_based(604800), + timelock::none(), + timelock::none(), + ); // 7 days in seconds let (admin_cap, _trail_id) = setup_test_audit_trail( &mut scenario, locking_config, @@ -180,7 +196,11 @@ fun test_create_multiple_trails() { // Create first trail { - let locking_config = locking::new(locking::window_count_based(0), timelock::none(), timelock::none()); + let locking_config = locking::new( + locking::window_count_based(0), + timelock::none(), + timelock::none(), + ); let (admin_cap1, trail_id1) = setup_test_audit_trail( &mut scenario, locking_config, @@ -195,7 +215,11 @@ fun test_create_multiple_trails() { // Create second trail { - let locking_config = locking::new(locking::window_count_based(0), timelock::none(), timelock::none()); + let locking_config = locking::new( + locking::window_count_based(0), + timelock::none(), + timelock::none(), + ); let (admin_cap2, trail_id2) = setup_test_audit_trail( &mut scenario, locking_config, @@ -221,7 +245,11 @@ fun test_create_metadata_admin_role() { // Creator creates the audit trail { - let locking_config = locking::new(locking::window_count_based(0), timelock::none(), timelock::none()); + let locking_config = locking::new( + locking::window_count_based(0), + timelock::none(), + timelock::none(), + ); let (admin_cap, trail_id) = setup_test_audit_trail( &mut scenario, diff --git a/audit-trail-move/tests/locking_tests.move b/audit-trail-move/tests/locking_tests.move index 13b4c020..b3bcbb9a 100644 --- a/audit-trail-move/tests/locking_tests.move +++ b/audit-trail-move/tests/locking_tests.move @@ -30,7 +30,11 @@ fun test_time_based_locking_within_window() { // Create trail with 1 hour time-based locking { - let locking_config = locking::new(locking::window_time_based(3600), timelock::none(), timelock::none()); + let locking_config = locking::new( + locking::window_time_based(3600), + timelock::none(), + timelock::none(), + ); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -70,7 +74,11 @@ fun test_time_based_locking_outside_window() { // Create trail with 1 hour time-based locking { - let locking_config = locking::new(locking::window_time_based(3600), timelock::none(), timelock::none()); + let locking_config = locking::new( + locking::window_time_based(3600), + timelock::none(), + timelock::none(), + ); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -108,7 +116,11 @@ fun test_count_based_locking() { // Create trail with count-based locking (last 2 locked) { - let locking_config = locking::new(locking::window_count_based(2), timelock::none(), timelock::none()); + let locking_config = locking::new( + locking::window_count_based(2), + timelock::none(), + timelock::none(), + ); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -190,7 +202,11 @@ fun test_count_based_locking_single_record() { // Create trail with "last 3 locked" - single record should be locked { - let locking_config = locking::new(locking::window_count_based(3), timelock::none(), timelock::none()); + let locking_config = locking::new( + locking::window_count_based(3), + timelock::none(), + timelock::none(), + ); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -222,7 +238,11 @@ fun test_no_locking() { let mut scenario = ts::begin(admin); { - let locking_config = locking::new(locking::window_none(), timelock::none(), timelock::none()); + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -256,7 +276,11 @@ fun test_update_locking_config() { // Create trail with no locking { - let locking_config = locking::new(locking::window_none(), timelock::none(), timelock::none()); + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -328,7 +352,11 @@ fun test_update_locking_config_permission_denied() { let mut scenario = ts::begin(admin); { - let locking_config = locking::new(locking::window_none(), timelock::none(), timelock::none()); + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -390,7 +418,11 @@ fun test_update_delete_record_window() { // Create trail with no locking { - let locking_config = locking::new(locking::window_none(), timelock::none(), timelock::none()); + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -465,7 +497,11 @@ fun test_update_delete_record_window_permission_denied() { let mut scenario = ts::begin(admin); { - let locking_config = locking::new(locking::window_none(), timelock::none(), timelock::none()); + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -528,7 +564,11 @@ fun test_delete_record_after_time_lock_expires() { // Create trail with 1 hour time-based locking and initial record { - let locking_config = locking::new(locking::window_time_based(3600), timelock::none(), timelock::none()); // 1 hour = 3600 seconds + let locking_config = locking::new( + locking::window_time_based(3600), + timelock::none(), + timelock::none(), + ); // 1 hour = 3600 seconds let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -615,7 +655,11 @@ fun test_time_lock_boundary_just_before_expiry() { // Create trail with 1 hour time-based locking { - let locking_config = locking::new(locking::window_time_based(3600), timelock::none(), timelock::none()); + let locking_config = locking::new( + locking::window_time_based(3600), + timelock::none(), + timelock::none(), + ); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -650,7 +694,11 @@ fun test_time_based_locking_all_recent_records_locked() { // Create trail with time-based (1 hour) locking { - let locking_config = locking::new(locking::window_time_based(3600), timelock::none(), timelock::none()); + let locking_config = locking::new( + locking::window_time_based(3600), + timelock::none(), + timelock::none(), + ); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -731,7 +779,11 @@ fun test_count_based_locking_last_records_remain_locked() { // Create trail with count-based (last 2) locking { - let locking_config = locking::new(locking::window_count_based(2), timelock::none(), timelock::none()); + let locking_config = locking::new( + locking::window_count_based(2), + timelock::none(), + timelock::none(), + ); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -814,7 +866,11 @@ fun test_time_based_locking_still_locked_before_expiry() { // Create trail with time-based (1 hour) locking { - let locking_config = locking::new(locking::window_time_based(3600), timelock::none(), timelock::none()); + let locking_config = locking::new( + locking::window_time_based(3600), + timelock::none(), + timelock::none(), + ); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -893,7 +949,11 @@ fun test_count_based_locking_old_record_can_delete() { // Create trail with count-based (last 2) locking { - let locking_config = locking::new(locking::window_count_based(2), timelock::none(), timelock::none()); + let locking_config = locking::new( + locking::window_count_based(2), + timelock::none(), + timelock::none(), + ); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -981,7 +1041,11 @@ fun test_delete_records_batch_bypasses_record_lock() { // Create trail with 1 hour delete lock and an initial record. { - let locking_config = locking::new(locking::window_time_based(3600), timelock::none(), timelock::none()); + let locking_config = locking::new( + locking::window_time_based(3600), + timelock::none(), + timelock::none(), + ); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -1039,7 +1103,11 @@ fun test_delete_records_batch_requires_delete_all_records_permission() { let mut scenario = ts::begin(admin); { - let locking_config = locking::new(locking::window_none(), timelock::none(), timelock::none()); + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -1102,7 +1170,11 @@ fun test_delete_audit_trail_fails_while_not_empty() { let mut scenario = ts::begin(admin); { - let locking_config = locking::new(locking::window_none(), timelock::none(), timelock::none()); + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -1153,7 +1225,11 @@ fun test_delete_audit_trail_after_batch_cleanup() { let mut scenario = ts::begin(admin); { - let locking_config = locking::new(locking::window_none(), timelock::none(), timelock::none()); + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, diff --git a/audit-trail-move/tests/metadata_tests.move b/audit-trail-move/tests/metadata_tests.move index f12a864c..12cb2773 100644 --- a/audit-trail-move/tests/metadata_tests.move +++ b/audit-trail-move/tests/metadata_tests.move @@ -27,7 +27,11 @@ fun test_update_metadata_success() { // Setup: Create audit trail { - let locking_config = locking::new(locking::window_count_based(0), timelock::none(), timelock::none()); + let locking_config = locking::new( + locking::window_count_based(0), + timelock::none(), + timelock::none(), + ); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -145,7 +149,11 @@ fun test_update_metadata_permission_denied() { // Setup { - let locking_config = locking::new(locking::window_count_based(0), timelock::none(), timelock::none()); + let locking_config = locking::new( + locking::window_count_based(0), + timelock::none(), + timelock::none(), + ); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -212,7 +220,11 @@ fun test_update_metadata_revoked_capability() { // Setup: Create audit trail { - let locking_config = locking::new(locking::window_count_based(0), timelock::none(), timelock::none()); + let locking_config = locking::new( + locking::window_count_based(0), + timelock::none(), + timelock::none(), + ); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, diff --git a/audit-trail-move/tests/record_tests.move b/audit-trail-move/tests/record_tests.move index 34b1950d..6130b02c 100644 --- a/audit-trail-move/tests/record_tests.move +++ b/audit-trail-move/tests/record_tests.move @@ -32,7 +32,11 @@ fun test_add_record_to_empty_trail() { // Setup trail { - let locking_config = locking::new(locking::window_none(), timelock::none(), timelock::none()); + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -106,7 +110,11 @@ fun test_add_multiple_records() { // Setup trail { - let locking_config = locking::new(locking::window_none(), timelock::none(), timelock::none()); + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -183,7 +191,11 @@ fun test_add_record_permission_denied() { // Setup trail { - let locking_config = locking::new(locking::window_none(), timelock::none(), timelock::none()); + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -251,7 +263,11 @@ fun test_delete_record_success() { // Setup trail with initial record { - let locking_config = locking::new(locking::window_none(), timelock::none(), timelock::none()); + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -320,7 +336,11 @@ fun test_delete_record_permission_denied() { // Setup trail with initial record { - let locking_config = locking::new(locking::window_none(), timelock::none(), timelock::none()); + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -381,7 +401,11 @@ fun test_delete_record_not_found() { // Setup trail (no initial record) { - let locking_config = locking::new(locking::window_none(), timelock::none(), timelock::none()); + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -441,7 +465,11 @@ fun test_delete_record_time_locked() { // Setup trail with time-based locking and initial record { - let locking_config = locking::new(locking::window_time_based(3600), timelock::none(), timelock::none()); // 1 hour + let locking_config = locking::new( + locking::window_time_based(3600), + timelock::none(), + timelock::none(), + ); // 1 hour let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -502,7 +530,11 @@ fun test_delete_record_count_locked() { // Setup trail with count-based locking and initial record { - let locking_config = locking::new(locking::window_count_based(5), timelock::none(), timelock::none()); // Last 5 records locked + let locking_config = locking::new( + locking::window_count_based(5), + timelock::none(), + timelock::none(), + ); // Last 5 records locked let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -563,7 +595,11 @@ fun test_get_record() { // Setup trail with initial record { - let locking_config = locking::new(locking::window_none(), timelock::none(), timelock::none()); + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); let initial_data = new_test_data(42, b"Test data"); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, @@ -597,7 +633,11 @@ fun test_get_record_not_found() { // Setup trail (no initial record) { - let locking_config = locking::new(locking::window_none(), timelock::none(), timelock::none()); + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -626,7 +666,11 @@ fun test_first_last_sequence() { // Setup trail { - let locking_config = locking::new(locking::window_none(), timelock::none(), timelock::none()); + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -715,7 +759,11 @@ fun test_is_record_locked_not_found() { // Setup trail (no initial record) { - let locking_config = locking::new(locking::window_time_based(3600), timelock::none(), timelock::none()); + let locking_config = locking::new( + locking::window_time_based(3600), + timelock::none(), + timelock::none(), + ); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, diff --git a/audit-trail-move/tests/role_tests.move b/audit-trail-move/tests/role_tests.move index 9c0b7108..8f76888b 100644 --- a/audit-trail-move/tests/role_tests.move +++ b/audit-trail-move/tests/role_tests.move @@ -29,7 +29,11 @@ fun test_role_based_permission_delegation() { // Step 1: admin_user creates the audit trail let trail_id = { - let locking_config = locking::new(locking::window_count_based(0), timelock::none(), timelock::none()); + let locking_config = locking::new( + locking::window_count_based(0), + timelock::none(), + timelock::none(), + ); let (admin_cap, trail_id) = setup_test_audit_trail( &mut scenario, @@ -216,7 +220,11 @@ fun test_delete_role_success() { let mut scenario = ts::begin(admin_user); { - let locking_config = locking::new(locking::window_count_based(0), timelock::none(), timelock::none()); + let locking_config = locking::new( + locking::window_count_based(0), + timelock::none(), + timelock::none(), + ); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -280,7 +288,11 @@ fun test_create_role_permission_denied() { // Setup { - let locking_config = locking::new(locking::window_count_based(0), timelock::none(), timelock::none()); + let locking_config = locking::new( + locking::window_count_based(0), + timelock::none(), + timelock::none(), + ); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -352,7 +364,11 @@ fun test_delete_role_permission_denied() { // Setup { - let locking_config = locking::new(locking::window_count_based(0), timelock::none(), timelock::none()); + let locking_config = locking::new( + locking::window_count_based(0), + timelock::none(), + timelock::none(), + ); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -428,7 +444,11 @@ fun test_update_role_permissions_permission_denied() { // Setup { - let locking_config = locking::new(locking::window_count_based(0), timelock::none(), timelock::none()); + let locking_config = locking::new( + locking::window_count_based(0), + timelock::none(), + timelock::none(), + ); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -510,7 +530,11 @@ fun test_get_role_permissions_nonexistent() { let mut scenario = ts::begin(admin_user); { - let locking_config = locking::new(locking::window_count_based(0), timelock::none(), timelock::none()); + let locking_config = locking::new( + locking::window_count_based(0), + timelock::none(), + timelock::none(), + ); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -539,7 +563,11 @@ fun test_update_role_permissions_success() { let mut scenario = ts::begin(admin_user); { - let locking_config = locking::new(locking::window_count_based(0), timelock::none(), timelock::none()); + let locking_config = locking::new( + locking::window_count_based(0), + timelock::none(), + timelock::none(), + ); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -600,7 +628,11 @@ fun test_update_role_permissions_nonexistent() { let mut scenario = ts::begin(admin_user); { - let locking_config = locking::new(locking::window_count_based(0), timelock::none(), timelock::none()); + let locking_config = locking::new( + locking::window_count_based(0), + timelock::none(), + timelock::none(), + ); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, diff --git a/audit-trail-rs/src/core/roles/transactions.rs b/audit-trail-rs/src/core/roles/transactions.rs index 30ab9299..3bfc2ead 100644 --- a/audit-trail-rs/src/core/roles/transactions.rs +++ b/audit-trail-rs/src/core/roles/transactions.rs @@ -13,7 +13,7 @@ use tokio::sync::OnceCell; use super::operations::RolesOps; use crate::core::types::{ CapabilityDestroyed, CapabilityIssueOptions, CapabilityIssued, CapabilityRevoked, Event, PermissionSet, - RoleCreated, RoleDeleted, RoleUpdated, + RoleCreated, RoleRemoved, RoleUpdated, }; use crate::error::Error; @@ -197,7 +197,7 @@ impl DeleteRole { #[cfg_attr(feature = "send-sync", async_trait)] impl Transaction for DeleteRole { type Error = Error; - type Output = RoleDeleted; + type Output = RoleRemoved; async fn build_programmable_transaction(&self, client: &C) -> Result where @@ -215,13 +215,13 @@ impl Transaction for DeleteRole { where C: CoreClientReadOnly + OptionalSync, { - for data in &events.data { - if let Ok(event) = serde_json::from_value::>(data.parsed_json.clone()) { - return Ok(event.data); - } - } + let event = events + .data + .iter() + .find_map(|data| serde_json::from_value::>(data.parsed_json.clone()).ok()) + .ok_or_else(|| Error::UnexpectedApiResponse("RoleRemoved event not found".to_string()))?; - Err(Error::UnexpectedApiResponse("RoleDeleted event not found".to_string())) + Ok(event.data) } async fn apply(mut self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result diff --git a/audit-trail-rs/src/core/types/event.rs b/audit-trail-rs/src/core/types/event.rs index d33651cd..5a73ca65 100644 --- a/audit-trail-rs/src/core/types/event.rs +++ b/audit-trail-rs/src/core/types/event.rs @@ -1,14 +1,10 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use std::collections::HashSet; - use iota_interaction::types::base_types::{IotaAddress, ObjectID}; use serde::{Deserialize, Serialize}; use serde_aux::field_attributes::{deserialize_number_from_string, deserialize_option_number_from_string}; -use super::permission::Permission; -use crate::core::utils::deserialize_vec_set; /// Generic wrapper for audit trail events. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Event { @@ -67,12 +63,6 @@ pub struct CapabilityIssued { pub struct CapabilityDestroyed { pub target_key: ObjectID, pub capability_id: ObjectID, - pub role: String, - pub issued_to: Option, - #[serde(deserialize_with = "deserialize_option_number_from_string")] - pub valid_from: Option, - #[serde(deserialize_with = "deserialize_option_number_from_string")] - pub valid_until: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -96,10 +86,8 @@ pub struct RoleUpdated { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct RoleDeleted { +pub struct RoleRemoved { + #[serde(rename = "target_key")] pub trail_id: ObjectID, pub role: String, - pub deleted_by: IotaAddress, - #[serde(deserialize_with = "deserialize_number_from_string")] - pub timestamp: u64, } diff --git a/audit-trail-rs/src/package.rs b/audit-trail-rs/src/package.rs index 65507534..f9acec41 100644 --- a/audit-trail-rs/src/package.rs +++ b/audit-trail-rs/src/package.rs @@ -33,7 +33,7 @@ static AUDIT_TRAIL_PACKAGE_REGISTRY: LazyLock> = LazyLoc /// Hardcoded TfComponents package ID used for timelock constructors. /// /// Update this value after publishing TfComponents. -const TF_COMPONENTS_PACKAGE_ID: &str = "0x3ad97a0004b116fde2bc8e183a64b79581a3248d995533a898717b689ad43a2c"; +const TF_COMPONENTS_PACKAGE_ID: &str = "0x5deb1782f8f078d7d85640099466c6513bee3ac261555fb06cb0bbe1f838ab17"; /// Returns a read lock to the package registry. pub(crate) async fn audit_trail_package_registry() -> PackageRegistryLock { diff --git a/audit-trail-rs/tests/e2e/roles.rs b/audit-trail-rs/tests/e2e/roles.rs index 2f43b3e4..07776c05 100644 --- a/audit-trail-rs/tests/e2e/roles.rs +++ b/audit-trail-rs/tests/e2e/roles.rs @@ -81,7 +81,6 @@ async fn delete_role_prevents_new_capability_issuance() -> anyhow::Result<()> { .output; assert_eq!(deleted.trail_id, trail_id); assert_eq!(deleted.role, role_name.to_string()); - assert!(deleted.timestamp > 0); let issue_tx = roles .for_role(role_name) @@ -171,10 +170,6 @@ async fn destroy_capability_emits_expected_event_data() -> anyhow::Result<()> { assert_eq!(destroyed.target_key, trail_id); assert_eq!(destroyed.capability_id, issued.capability_id); - assert_eq!(destroyed.role, role_name.to_string()); - assert_eq!(destroyed.issued_to, None); - assert_eq!(destroyed.valid_from, None); - assert_eq!(destroyed.valid_until, None); Ok(()) } @@ -196,10 +191,6 @@ async fn destroy_initial_admin_capability_emits_expected_event() -> anyhow::Resu assert_eq!(destroyed.target_key, trail_id); assert_eq!(destroyed.capability_id, admin_cap_id); - assert_eq!(destroyed.role, "Admin".to_string()); - assert_eq!(destroyed.issued_to, None); - assert_eq!(destroyed.valid_from, None); - assert_eq!(destroyed.valid_until, None); Ok(()) } diff --git a/bindings/wasm/notarization_wasm/Cargo.toml b/bindings/wasm/notarization_wasm/Cargo.toml index 9a04177b..bbd00d66 100644 --- a/bindings/wasm/notarization_wasm/Cargo.toml +++ b/bindings/wasm/notarization_wasm/Cargo.toml @@ -21,8 +21,8 @@ async-trait = { version = "0.1", default-features = false } bcs = "0.1.6" console_error_panic_hook = { version = "0.1" } fastcrypto = { git = "https://github.com/MystenLabs/fastcrypto", rev = "69d496c71fb37e3d22fe85e5bbfd4256d61422b9", package = "fastcrypto" } -iota_interaction = { git = "https://github.com/iotaledger/product-core.git", branch = "feat/emit-events-for-capabilities", package = "iota_interaction", default-features = false } -iota_interaction_ts = { git = "https://github.com/iotaledger/product-core.git", branch = "feat/emit-events-for-capabilities", package = "iota_interaction_ts" } +iota_interaction = { git = "https://github.com/iotaledger/product-core.git", branch = "feat/tf-compoenents-dev", package = "iota_interaction", default-features = false } +iota_interaction_ts = { git = "https://github.com/iotaledger/product-core.git", branch = "feat/tf-compoenents-dev", package = "iota_interaction_ts" } js-sys = { version = "0.3.61" } prefix-hex = { version = "0.7", default-features = false } serde = { version = "1.0", features = ["derive"] } @@ -37,7 +37,7 @@ wasm-bindgen-futures = { version = "0.4", default-features = false } [dependencies.product_common] git = "https://github.com/iotaledger/product-core.git" -branch = "feat/emit-events-for-capabilities" +branch = "feat/tf-compoenents-dev" package = "product_common" features = [ "core-client", From bf12e8a943ff319cf902cff81e1771c60818d355 Mon Sep 17 00:00:00 2001 From: Yasir Date: Tue, 3 Mar 2026 17:30:01 +0300 Subject: [PATCH 067/189] feat: Clean up unused imports and improve move_type implementation in RoleMap --- audit-trail-rs/src/core/operations.rs | 4 +--- audit-trail-rs/src/core/types/role_map.rs | 6 ++---- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/audit-trail-rs/src/core/operations.rs b/audit-trail-rs/src/core/operations.rs index a2aac138..8b4bb642 100644 --- a/audit-trail-rs/src/core/operations.rs +++ b/audit-trail-rs/src/core/operations.rs @@ -4,9 +4,7 @@ use std::collections::HashSet; use std::str::FromStr; -use iota_interaction::rpc_types::{ - IotaData as _, IotaObjectDataOptions, IotaTransactionBlockEffects, IotaTransactionBlockEvents, -}; +use iota_interaction::rpc_types::{IotaData as _, IotaObjectDataOptions}; use iota_interaction::types::base_types::{IotaAddress, ObjectID, ObjectRef}; use iota_interaction::types::programmable_transaction_builder::ProgrammableTransactionBuilder; use iota_interaction::types::transaction::{Argument, ObjectArg, ProgrammableTransaction}; diff --git a/audit-trail-rs/src/core/types/role_map.rs b/audit-trail-rs/src/core/types/role_map.rs index 7de465da..d9936b38 100644 --- a/audit-trail-rs/src/core/types/role_map.rs +++ b/audit-trail-rs/src/core/types/role_map.rs @@ -63,9 +63,7 @@ pub struct Capability { } impl MoveType for Capability { - fn move_type(_: ObjectID) -> TypeTag { - let tf_components_package_id = package::tf_components_package_id(); - TypeTag::from_str(format!("{tf_components_package_id}::capability::Capability").as_str()) - .expect("failed to create type tag") + fn move_type(object_id: ObjectID) -> TypeTag { + TypeTag::from_str(format!("{object_id}::capability::Capability").as_str()).expect("failed to create type tag") } } From ce4ea239518ba19d4ec29f0449eec202693253fe Mon Sep 17 00:00:00 2001 From: chrisgitiota Date: Thu, 5 Mar 2026 10:27:53 +0100 Subject: [PATCH 068/189] Use TfComponents from product-core "feat/tf-compoenents-dev" branch --- audit-trail-move/Move.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/audit-trail-move/Move.toml b/audit-trail-move/Move.toml index c83e2eed..3c6e966c 100644 --- a/audit-trail-move/Move.toml +++ b/audit-trail-move/Move.toml @@ -3,7 +3,7 @@ name = "audit_trail" edition = "2024.beta" [dependencies] -TfComponents = { local = "../../product-core/components_move" } +TfComponents = { git = "https://github.com/iotaledger/product-core.git", subdir = "components_move", rev = "feat/tf-compoenents-dev" } [addresses] audit_trail = "0x0" From 76db05b50bc62c6c76308ae6da39fff3bc29bebd Mon Sep 17 00:00:00 2001 From: Yasir Date: Mon, 9 Mar 2026 14:03:07 +0300 Subject: [PATCH 069/189] feat(audit_trails_wasm): implement WASM bindings for audit trails - Add Rust toolchain configuration for WASM. - Create builder for audit trail with methods for setting initial records, metadata, and locking configurations. - Implement client for creating and managing audit trails, including read-only access. - Introduce client read-only functionality for fetching audit trail details. - Define trail handling and record management, including adding, deleting, and listing records. - Implement types for handling data, locking configurations, and metadata in WASM. - Set up TypeScript configuration for WASM bindings and documentation generation. - Remove unnecessary hyper dependency from notarization-rs. --- Cargo.toml | 11 +- audit-trail-move/Move.lock | 16 +- audit-trail-move/Move.toml | 2 +- audit-trail-move/scripts/publish_package.sh | 2 +- audit-trail-move/sources/audit_trail.move | 13 +- audit-trail-rs/Cargo.toml | 1 - audit-trail-rs/src/client/full_client.rs | 27 +- audit-trail-rs/src/client/read_only.rs | 6 +- audit-trail-rs/src/core/builder.rs | 2 +- .../src/core/create/transactions.rs | 3 +- audit-trail-rs/src/core/records/mod.rs | 5 +- audit-trail-rs/src/core/trail.rs | 3 +- audit-trail-rs/src/core/types/role_map.rs | 7 +- audit-trail-rs/src/error.rs | 5 - audit-trail-rs/src/package.rs | 2 +- bindings/wasm/audit_trails_wasm/Cargo.toml | 44 + bindings/wasm/audit_trails_wasm/README.md | 29 + .../audit_trails_wasm/docs/wasm/api_ref.md | 9 + .../docs/wasm/audit_trails_wasm/api_ref.md | 9 + .../classes/DefaultHttpClient.md | 7 + .../wasm/audit_trails_wasm/examples/README.md | 40 + .../examples/src/01_create_trail.ts | 21 + .../examples/src/02_fetch_trail.ts | 19 + .../examples/src/03_add_and_list_records.ts | 37 + .../examples/src/04_delete_records_batch.ts | 26 + .../audit_trails_wasm/examples/src/main.ts | 31 + .../audit_trails_wasm/examples/src/tests.ts | 31 + .../audit_trails_wasm/examples/src/util.ts | 72 + .../examples/src/web-main.ts | 31 + .../examples/tsconfig.node.json | 22 + .../examples/tsconfig.web.json | 22 + bindings/wasm/audit_trails_wasm/lib/index.ts | 5 + .../wasm/audit_trails_wasm/lib/tsconfig.json | 21 + .../audit_trails_wasm/lib/tsconfig.web.json | 22 + .../wasm/audit_trails_wasm/package-lock.json | 2312 +++++++++++++++++ bindings/wasm/audit_trails_wasm/package.json | 63 + .../audit_trails_wasm/rust-toolchain.toml | 5 + .../wasm/audit_trails_wasm/src/builder.rs | 54 + bindings/wasm/audit_trails_wasm/src/client.rs | 83 + .../audit_trails_wasm/src/client_read_only.rs | 70 + bindings/wasm/audit_trails_wasm/src/lib.rs | 54 + bindings/wasm/audit_trails_wasm/src/trail.rs | 242 ++ .../audit_trails_wasm/src/trail_handle.rs | 74 + .../audit_trails_wasm/src/trail_records.rs | 115 + bindings/wasm/audit_trails_wasm/src/types.rs | 355 +++ bindings/wasm/audit_trails_wasm/tsconfig.json | 11 + .../wasm/audit_trails_wasm/tsconfig.node.json | 7 + .../audit_trails_wasm/tsconfig.typedoc.json | 6 + bindings/wasm/audit_trails_wasm/typedoc.json | 11 + notarization-rs/Cargo.toml | 1 - 50 files changed, 4023 insertions(+), 43 deletions(-) create mode 100644 bindings/wasm/audit_trails_wasm/Cargo.toml create mode 100644 bindings/wasm/audit_trails_wasm/docs/wasm/api_ref.md create mode 100644 bindings/wasm/audit_trails_wasm/docs/wasm/audit_trails_wasm/api_ref.md create mode 100644 bindings/wasm/audit_trails_wasm/docs/wasm/audit_trails_wasm/classes/DefaultHttpClient.md create mode 100644 bindings/wasm/audit_trails_wasm/examples/README.md create mode 100644 bindings/wasm/audit_trails_wasm/examples/src/01_create_trail.ts create mode 100644 bindings/wasm/audit_trails_wasm/examples/src/02_fetch_trail.ts create mode 100644 bindings/wasm/audit_trails_wasm/examples/src/03_add_and_list_records.ts create mode 100644 bindings/wasm/audit_trails_wasm/examples/src/04_delete_records_batch.ts create mode 100644 bindings/wasm/audit_trails_wasm/examples/src/main.ts create mode 100644 bindings/wasm/audit_trails_wasm/examples/src/tests.ts create mode 100644 bindings/wasm/audit_trails_wasm/examples/src/util.ts create mode 100644 bindings/wasm/audit_trails_wasm/examples/src/web-main.ts create mode 100644 bindings/wasm/audit_trails_wasm/examples/tsconfig.node.json create mode 100644 bindings/wasm/audit_trails_wasm/examples/tsconfig.web.json create mode 100644 bindings/wasm/audit_trails_wasm/lib/index.ts create mode 100644 bindings/wasm/audit_trails_wasm/lib/tsconfig.json create mode 100644 bindings/wasm/audit_trails_wasm/lib/tsconfig.web.json create mode 100644 bindings/wasm/audit_trails_wasm/package-lock.json create mode 100644 bindings/wasm/audit_trails_wasm/package.json create mode 100644 bindings/wasm/audit_trails_wasm/rust-toolchain.toml create mode 100644 bindings/wasm/audit_trails_wasm/src/builder.rs create mode 100644 bindings/wasm/audit_trails_wasm/src/client.rs create mode 100644 bindings/wasm/audit_trails_wasm/src/client_read_only.rs create mode 100644 bindings/wasm/audit_trails_wasm/src/lib.rs create mode 100644 bindings/wasm/audit_trails_wasm/src/trail.rs create mode 100644 bindings/wasm/audit_trails_wasm/src/trail_handle.rs create mode 100644 bindings/wasm/audit_trails_wasm/src/trail_records.rs create mode 100644 bindings/wasm/audit_trails_wasm/src/types.rs create mode 100644 bindings/wasm/audit_trails_wasm/tsconfig.json create mode 100644 bindings/wasm/audit_trails_wasm/tsconfig.node.json create mode 100644 bindings/wasm/audit_trails_wasm/tsconfig.typedoc.json create mode 100644 bindings/wasm/audit_trails_wasm/typedoc.json diff --git a/Cargo.toml b/Cargo.toml index 378a4e4f..ff2ee698 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,19 +9,18 @@ rust-version = "1.85" [workspace] resolver = "2" members = ["audit-trail-rs", "examples", "notarization-rs"] -exclude = ["bindings/wasm/notarization_wasm"] +exclude = ["bindings/wasm/notarization_wasm", "bindings/wasm/audit_trails_wasm"] [workspace.dependencies] anyhow = "1.0" async-trait = "0.1" bcs = "0.1" chrono = { version = "0.4", default-features = false } -hyper = "1" iota-sdk = { git = "https://github.com/iotaledger/iota.git", package = "iota-sdk", tag = "v1.17.2" } -iota_interaction = { git = "https://github.com/iotaledger/product-core.git", branch = "feat/tf-compoenents-dev", default-features = false, package = "iota_interaction" } -iota_interaction_rust = { git = "https://github.com/iotaledger/product-core.git", branch = "feat/tf-compoenents-dev", default-features = false, package = "iota_interaction_rust" } -iota_interaction_ts = { git = "https://github.com/iotaledger/product-core.git", branch = "feat/tf-compoenents-dev", default-features = false, package = "iota_interaction_ts" } -product_common = { git = "https://github.com/iotaledger/product-core.git", branch = "feat/tf-compoenents-dev", default-features = false, package = "product_common" } +iota_interaction = { git = "https://github.com/iotaledger/product-core.git", rev = "23225ad2688f04ab45bedda7fda4d7cbc92ccbdf", default-features = false, package = "iota_interaction" } +iota_interaction_rust = { git = "https://github.com/iotaledger/product-core.git", rev = "23225ad2688f04ab45bedda7fda4d7cbc92ccbdf", default-features = false, package = "iota_interaction_rust" } +iota_interaction_ts = { git = "https://github.com/iotaledger/product-core.git", rev = "23225ad2688f04ab45bedda7fda4d7cbc92ccbdf", default-features = false, package = "iota_interaction_ts" } +product_common = { git = "https://github.com/iotaledger/product-core.git", rev = "23225ad2688f04ab45bedda7fda4d7cbc92ccbdf", default-features = false, package = "product_common" } secret-storage = { git = "https://github.com/iotaledger/secret-storage.git", tag = "v0.3.0", default-features = false } serde = { version = "1.0", default-features = false, features = ["alloc", "derive"] } serde-aux = { version = "4.7.0", default-features = false } diff --git a/audit-trail-move/Move.lock b/audit-trail-move/Move.lock index c75ab983..37c3de1d 100644 --- a/audit-trail-move/Move.lock +++ b/audit-trail-move/Move.lock @@ -14,7 +14,7 @@ dependencies = [ [[move.package]] id = "Iota" -source = { git = "https://github.com/iotaledger/iota.git", rev = "22dd2a89f5b591066c6930510a227c087cc836ac", subdir = "crates/iota-framework/packages/iota-framework" } +source = { git = "https://github.com/iotaledger/iota.git", rev = "431ab686e6f1d4abd83b16dd7c671712002ecac8", subdir = "crates/iota-framework/packages/iota-framework" } dependencies = [ { id = "MoveStdlib", name = "MoveStdlib" }, @@ -22,7 +22,7 @@ dependencies = [ [[move.package]] id = "IotaSystem" -source = { git = "https://github.com/iotaledger/iota.git", rev = "22dd2a89f5b591066c6930510a227c087cc836ac", subdir = "crates/iota-framework/packages/iota-system" } +source = { git = "https://github.com/iotaledger/iota.git", rev = "431ab686e6f1d4abd83b16dd7c671712002ecac8", subdir = "crates/iota-framework/packages/iota-system" } dependencies = [ { id = "Iota", name = "Iota" }, @@ -31,11 +31,11 @@ dependencies = [ [[move.package]] id = "MoveStdlib" -source = { git = "https://github.com/iotaledger/iota.git", rev = "22dd2a89f5b591066c6930510a227c087cc836ac", subdir = "crates/iota-framework/packages/move-stdlib" } +source = { git = "https://github.com/iotaledger/iota.git", rev = "431ab686e6f1d4abd83b16dd7c671712002ecac8", subdir = "crates/iota-framework/packages/move-stdlib" } [[move.package]] id = "Stardust" -source = { git = "https://github.com/iotaledger/iota.git", rev = "22dd2a89f5b591066c6930510a227c087cc836ac", subdir = "crates/iota-framework/packages/stardust" } +source = { git = "https://github.com/iotaledger/iota.git", rev = "431ab686e6f1d4abd83b16dd7c671712002ecac8", subdir = "crates/iota-framework/packages/stardust" } dependencies = [ { id = "Iota", name = "Iota" }, @@ -54,14 +54,14 @@ dependencies = [ ] [move.toolchain-version] -compiler-version = "1.17.2" +compiler-version = "1.18.1-rc" edition = "2024.beta" flavor = "iota" [env] [env.localnet] -chain-id = "426effa0" -original-published-id = "0x4644007b931d759798353f3a1f631f690658f04e3111d8438d211033d35fdd34" -latest-published-id = "0x4644007b931d759798353f3a1f631f690658f04e3111d8438d211033d35fdd34" +chain-id = "23b95e0a" +original-published-id = "0x5f3ab461e86648d9126e87fb3f820fc731a194bdd7702ee5a307ba0bbbd58670" +latest-published-id = "0x5f3ab461e86648d9126e87fb3f820fc731a194bdd7702ee5a307ba0bbbd58670" published-version = "1" diff --git a/audit-trail-move/Move.toml b/audit-trail-move/Move.toml index 3c6e966c..c83e2eed 100644 --- a/audit-trail-move/Move.toml +++ b/audit-trail-move/Move.toml @@ -3,7 +3,7 @@ name = "audit_trail" edition = "2024.beta" [dependencies] -TfComponents = { git = "https://github.com/iotaledger/product-core.git", subdir = "components_move", rev = "feat/tf-compoenents-dev" } +TfComponents = { local = "../../product-core/components_move" } [addresses] audit_trail = "0x0" diff --git a/audit-trail-move/scripts/publish_package.sh b/audit-trail-move/scripts/publish_package.sh index b04c848f..b1d087e6 100755 --- a/audit-trail-move/scripts/publish_package.sh +++ b/audit-trail-move/scripts/publish_package.sh @@ -6,7 +6,7 @@ script_dir=$(cd "$(dirname $0)" && pwd) package_dir=$script_dir/.. -RESPONSE=$(iota client publish --silence-warnings --json --gas-budget 500000000 $package_dir) +RESPONSE=$(iota client publish --silence-warnings --json --gas-budget 500000000 $package_dir) { # try PACKAGE_ID=$(echo $RESPONSE | jq --raw-output '.objectChanges[] | select(.type | contains("published")) | .packageId') } || { # catch diff --git a/audit-trail-move/sources/audit_trail.move b/audit-trail-move/sources/audit_trail.move index 30432441..62a92bc6 100644 --- a/audit-trail-move/sources/audit_trail.move +++ b/audit-trail-move/sources/audit_trail.move @@ -8,14 +8,21 @@ module audit_trail::main; use audit_trail::{ - locking::{Self, LockingConfig, LockingWindow, set_config, set_delete_record_window, set_delete_trail_lock, set_write_lock}, + locking::{ + Self, + LockingConfig, + LockingWindow, + set_config, + set_delete_record_window, + set_delete_trail_lock, + set_write_lock + }, permission::{Self, Permission}, record::{Self, Record} }; use iota::{clock::{Self, Clock}, event, linked_table::{Self, LinkedTable}, vec_set::VecSet}; use std::string::String; -use tf_components::{capability::Capability, role_map::{Self, RoleMap}}; -use tf_components::timelock::TimeLock; +use tf_components::{capability::Capability, role_map::{Self, RoleMap}, timelock::TimeLock}; // ===== Errors ===== #[error] diff --git a/audit-trail-rs/Cargo.toml b/audit-trail-rs/Cargo.toml index 2077a7e1..2b3393c4 100644 --- a/audit-trail-rs/Cargo.toml +++ b/audit-trail-rs/Cargo.toml @@ -27,7 +27,6 @@ thiserror.workspace = true [target.'cfg(not(target_arch = "wasm32"))'.dependencies] iota_interaction_rust = { workspace = true, default-features = false } -hyper = { workspace = true } # Fix for iota-sdk 1.13 issue with axum-server. iota-sdk = { workspace = true } tokio = { workspace = true } diff --git a/audit-trail-rs/src/client/full_client.rs b/audit-trail-rs/src/client/full_client.rs index e5d5a0ee..f2bbcd5a 100644 --- a/audit-trail-rs/src/client/full_client.rs +++ b/audit-trail-rs/src/client/full_client.rs @@ -10,14 +10,12 @@ use std::ops::Deref; use async_trait::async_trait; #[cfg(not(target_arch = "wasm32"))] use iota_interaction::IotaClient; -use iota_interaction::types::base_types::ObjectID; +use iota_interaction::types::base_types::{IotaAddress, ObjectID}; +use iota_interaction::types::crypto::PublicKey; use iota_interaction::types::transaction::ProgrammableTransaction; use iota_interaction::{IotaKeySignature, OptionalSync}; -use iota_interaction_rust::IotaClientAdapter; #[cfg(target_arch = "wasm32")] use iota_interaction_ts::bindings::WasmIotaClient as IotaClient; -use iota_sdk::types::base_types::IotaAddress; -use iota_sdk::types::crypto::PublicKey; use product_common::core_client::{CoreClient, CoreClientReadOnly}; use product_common::network_name::NetworkName; use secret_storage::Signer; @@ -27,6 +25,7 @@ use crate::client::read_only::AuditTrailClientReadOnly; use crate::core::builder::AuditTrailBuilder; use crate::core::trail::{AuditTrailFull, AuditTrailHandle, AuditTrailReadOnly}; use crate::error::Error; +use crate::iota_interaction_adapter::IotaClientAdapter; /// A marker type indicating the absence of a signer. #[derive(Debug, Clone, Copy)] @@ -120,6 +119,23 @@ impl AuditTrailClient { } impl AuditTrailClient { + /// Creates a new client with signing capabilities from an existing read-only client. + pub async fn new(client: AuditTrailClientReadOnly, signer: S) -> Result + where + S: Signer, + { + let public_key = signer + .public_key() + .await + .map_err(|e| Error::InvalidKey(e.to_string()))?; + + Ok(AuditTrailClient { + read_client: client, + public_key: Some(public_key), + signer, + }) + } + /// Sets a new signer for this client. pub async fn with_signer(self, signer: NewS) -> Result, secret_storage::Error> where @@ -205,7 +221,8 @@ where } } -#[async_trait::async_trait] +#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] +#[cfg_attr(feature = "send-sync", async_trait)] impl AuditTrailReadOnly for AuditTrailClient where S: Signer + OptionalSync, diff --git a/audit-trail-rs/src/client/read_only.rs b/audit-trail-rs/src/client/read_only.rs index 8cc4e5a7..07a63451 100644 --- a/audit-trail-rs/src/client/read_only.rs +++ b/audit-trail-rs/src/client/read_only.rs @@ -135,7 +135,8 @@ impl AuditTrailClientReadOnly { } } -#[async_trait::async_trait] +#[cfg_attr(not(feature = "send-sync"), async_trait::async_trait(?Send))] +#[cfg_attr(feature = "send-sync", async_trait::async_trait)] impl CoreClientReadOnly for AuditTrailClientReadOnly { fn package_id(&self) -> ObjectID { self.audit_trail_pkg_id @@ -150,7 +151,8 @@ impl CoreClientReadOnly for AuditTrailClientReadOnly { } } -#[async_trait::async_trait] +#[cfg_attr(not(feature = "send-sync"), async_trait::async_trait(?Send))] +#[cfg_attr(feature = "send-sync", async_trait::async_trait)] impl AuditTrailReadOnly for AuditTrailClientReadOnly { async fn execute_read_only_transaction( &self, diff --git a/audit-trail-rs/src/core/builder.rs b/audit-trail-rs/src/core/builder.rs index 1d6e070a..bd976405 100644 --- a/audit-trail-rs/src/core/builder.rs +++ b/audit-trail-rs/src/core/builder.rs @@ -3,7 +3,7 @@ //! Audit trail builder for creation transactions. -use iota_sdk::types::base_types::IotaAddress; +use iota_interaction::types::base_types::IotaAddress; use product_common::transaction::transaction_builder::TransactionBuilder; use super::types::{Data, ImmutableMetadata, LockingConfig}; diff --git a/audit-trail-rs/src/core/create/transactions.rs b/audit-trail-rs/src/core/create/transactions.rs index 9b714353..68c7b563 100644 --- a/audit-trail-rs/src/core/create/transactions.rs +++ b/audit-trail-rs/src/core/create/transactions.rs @@ -4,9 +4,8 @@ use async_trait::async_trait; use iota_interaction::OptionalSync; use iota_interaction::rpc_types::{IotaTransactionBlockEffects, IotaTransactionBlockEvents}; -use iota_interaction::types::base_types::ObjectID; +use iota_interaction::types::base_types::{IotaAddress, ObjectID}; use iota_interaction::types::transaction::ProgrammableTransaction; -use iota_sdk::types::base_types::IotaAddress; use product_common::core_client::CoreClientReadOnly; use product_common::transaction::transaction_builder::Transaction; use tokio::sync::OnceCell; diff --git a/audit-trail-rs/src/core/records/mod.rs b/audit-trail-rs/src/core/records/mod.rs index 7509b050..91e11235 100644 --- a/audit-trail-rs/src/core/records/mod.rs +++ b/audit-trail-rs/src/core/records/mod.rs @@ -3,8 +3,7 @@ use std::collections::{BTreeMap, HashMap}; -use iota_interaction::move_types::annotated_value::MoveValue; -use iota_interaction::rpc_types::{IotaData as _, IotaMoveValue, IotaObjectDataOptions}; +use iota_interaction::rpc_types::{IotaData as _, IotaObjectDataOptions}; use iota_interaction::types::TypeTag; use iota_interaction::types::base_types::ObjectID; use iota_interaction::types::collection_types::{LinkedTable, LinkedTableNode}; @@ -237,7 +236,7 @@ where { let name = DynamicFieldName { type_: TypeTag::U64, - value: IotaMoveValue::from(MoveValue::U64(key)).to_json_value(), + value: serde_json::Value::String(key.to_string()), }; let data = client diff --git a/audit-trail-rs/src/core/trail.rs b/audit-trail-rs/src/core/trail.rs index 8b500191..e9eb8cce 100644 --- a/audit-trail-rs/src/core/trail.rs +++ b/audit-trail-rs/src/core/trail.rs @@ -25,7 +25,8 @@ pub use transactions::{DeleteAuditTrail, Migrate, UpdateMetadata}; /// Marker trait for read-only audit trail clients. #[doc(hidden)] -#[async_trait::async_trait] +#[cfg_attr(not(feature = "send-sync"), async_trait::async_trait(?Send))] +#[cfg_attr(feature = "send-sync", async_trait::async_trait)] pub trait AuditTrailReadOnly: CoreClientReadOnly + OptionalSync { async fn execute_read_only_transaction(&self, tx: ProgrammableTransaction) -> Result; diff --git a/audit-trail-rs/src/core/types/role_map.rs b/audit-trail-rs/src/core/types/role_map.rs index d9936b38..7dcd424e 100644 --- a/audit-trail-rs/src/core/types/role_map.rs +++ b/audit-trail-rs/src/core/types/role_map.rs @@ -63,7 +63,10 @@ pub struct Capability { } impl MoveType for Capability { - fn move_type(object_id: ObjectID) -> TypeTag { - TypeTag::from_str(format!("{object_id}::capability::Capability").as_str()).expect("failed to create type tag") + fn move_type(_: ObjectID) -> TypeTag { + // TODO: replace with actual type tag once TfComponents is published and the package ID is known + let tf_components_package_id = package::tf_components_package_id(); + TypeTag::from_str(format!("{tf_components_package_id}::capability::Capability").as_str()) + .expect("failed to create type tag") } } diff --git a/audit-trail-rs/src/error.rs b/audit-trail-rs/src/error.rs index 79f75834..1eb39585 100644 --- a/audit-trail-rs/src/error.rs +++ b/audit-trail-rs/src/error.rs @@ -41,8 +41,3 @@ pub enum Error { #[error("unexpected transaction response: {0}")] TransactionUnexpectedResponse(String), } - -#[cfg(target_arch = "wasm32")] -use product_common::impl_wasm_error_from; -#[cfg(target_arch = "wasm32")] -impl_wasm_error_from!(Error); diff --git a/audit-trail-rs/src/package.rs b/audit-trail-rs/src/package.rs index f9acec41..d7e5b7ae 100644 --- a/audit-trail-rs/src/package.rs +++ b/audit-trail-rs/src/package.rs @@ -33,7 +33,7 @@ static AUDIT_TRAIL_PACKAGE_REGISTRY: LazyLock> = LazyLoc /// Hardcoded TfComponents package ID used for timelock constructors. /// /// Update this value after publishing TfComponents. -const TF_COMPONENTS_PACKAGE_ID: &str = "0x5deb1782f8f078d7d85640099466c6513bee3ac261555fb06cb0bbe1f838ab17"; +const TF_COMPONENTS_PACKAGE_ID: &str = "0xb4b92d36ae3e1ccfef40cea5c503ce2f6ea356f35a84a0f6a5ded11691db4435"; /// Returns a read lock to the package registry. pub(crate) async fn audit_trail_package_registry() -> PackageRegistryLock { diff --git a/bindings/wasm/audit_trails_wasm/Cargo.toml b/bindings/wasm/audit_trails_wasm/Cargo.toml new file mode 100644 index 00000000..2e05c0d8 --- /dev/null +++ b/bindings/wasm/audit_trails_wasm/Cargo.toml @@ -0,0 +1,44 @@ +[package] +name = "audit_trails_wasm" +version = "0.1.0-alpha" +authors = ["IOTA Stiftung"] +edition = "2021" +homepage = "https://www.iota.org" +keywords = ["iota", "tangle", "audit-trail", "wasm"] +license = "Apache-2.0" +publish = false +readme = "README.md" +repository = "https://github.com/iotaledger/notarization.git" +resolver = "2" +description = "Web Assembly bindings for the audit_trails crate." + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +anyhow = "1.0.95" +audit_trails = { path = "../../../audit-trail-rs", default-features = false, features = ["gas-station", "default-http-client"] } +bcs = "0.1.6" +console_error_panic_hook = { version = "0.1" } +iota_interaction = { git = "https://github.com/iotaledger/product-core.git", rev = "23225ad2688f04ab45bedda7fda4d7cbc92ccbdf", package = "iota_interaction", default-features = false } +iota_interaction_ts = { git = "https://github.com/iotaledger/product-core.git", rev = "23225ad2688f04ab45bedda7fda4d7cbc92ccbdf", package = "iota_interaction_ts" } +js-sys = { version = "0.3.61" } +product_common = { git = "https://github.com/iotaledger/product-core.git", rev = "23225ad2688f04ab45bedda7fda4d7cbc92ccbdf", package = "product_common", features = ["core-client", "transaction", "bindings", "binding-utils", "gas-station", "default-http-client"] } +serde = { version = "1.0", features = ["derive"] } +serde-wasm-bindgen = "0.6.5" +tokio = { version = "1.49.0", default-features = false, features = ["sync"] } +wasm-bindgen = { version = "0.2.100", features = ["serde-serialize"] } +wasm-bindgen-futures = { version = "0.4", default-features = false } + +[target.'cfg(all(target_arch = "wasm32", not(target_os = "wasi")))'.dependencies] +getrandom = { version = "0.3", default-features = false, features = ["wasm_js"] } + +[profile.release] +opt-level = 's' +lto = true + +[lints.clippy] +empty_docs = "allow" + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(wasm_bindgen_unstable_test_coverage)'] } diff --git a/bindings/wasm/audit_trails_wasm/README.md b/bindings/wasm/audit_trails_wasm/README.md index dcd4113e..6cfde375 100644 --- a/bindings/wasm/audit_trails_wasm/README.md +++ b/bindings/wasm/audit_trails_wasm/README.md @@ -1 +1,30 @@ # IOTA Audit Trails WASM Library + +`audit_trails_wasm` provides the Rust-to-WASM bindings for the `audit_trails` crate and is published to JavaScript consumers as `@iota/audit-trails`. + +The current MVP surface includes: + +- `AuditTrailClientReadOnly` +- `AuditTrailClient` +- `AuditTrailBuilder` +- `AuditTrailHandle` +- `TrailRecords` +- `Data` +- `Record` +- `PaginatedRecord` +- `OnChainAuditTrail` +- `ImmutableMetadata` +- `LockingConfig` +- `LockingWindow` +- `TimeLock` + +## Build + +```bash +npm install +npm run build +``` + +## Examples + +See [examples/README.md](./examples/README.md) for the node example flows. diff --git a/bindings/wasm/audit_trails_wasm/docs/wasm/api_ref.md b/bindings/wasm/audit_trails_wasm/docs/wasm/api_ref.md new file mode 100644 index 00000000..7762f30e --- /dev/null +++ b/bindings/wasm/audit_trails_wasm/docs/wasm/api_ref.md @@ -0,0 +1,9 @@ +**@iota/audit-trails API documentation** + +*** + +# @iota/audit-trails API documentation + +## Modules + +- [audit\_trails\_wasm](audit_trails_wasm/api_ref.md) diff --git a/bindings/wasm/audit_trails_wasm/docs/wasm/audit_trails_wasm/api_ref.md b/bindings/wasm/audit_trails_wasm/docs/wasm/audit_trails_wasm/api_ref.md new file mode 100644 index 00000000..d910b5ce --- /dev/null +++ b/bindings/wasm/audit_trails_wasm/docs/wasm/audit_trails_wasm/api_ref.md @@ -0,0 +1,9 @@ +[**@iota/audit-trails API documentation**](../api_ref.md) + +*** + +# audit\_trails\_wasm + +## Classes + +- [DefaultHttpClient](classes/DefaultHttpClient.md) diff --git a/bindings/wasm/audit_trails_wasm/docs/wasm/audit_trails_wasm/classes/DefaultHttpClient.md b/bindings/wasm/audit_trails_wasm/docs/wasm/audit_trails_wasm/classes/DefaultHttpClient.md new file mode 100644 index 00000000..62216357 --- /dev/null +++ b/bindings/wasm/audit_trails_wasm/docs/wasm/audit_trails_wasm/classes/DefaultHttpClient.md @@ -0,0 +1,7 @@ +[**@iota/audit-trails API documentation**](../../api_ref.md) + +*** + +# Class: DefaultHttpClient + +A default implementation for HttpClient. diff --git a/bindings/wasm/audit_trails_wasm/examples/README.md b/bindings/wasm/audit_trails_wasm/examples/README.md new file mode 100644 index 00000000..797621f9 --- /dev/null +++ b/bindings/wasm/audit_trails_wasm/examples/README.md @@ -0,0 +1,40 @@ +# IOTA Audit Trails WASM Examples + +The examples in this folder demonstrate the Core MVP flow of the `@iota/audit-trails` package: + +- create a trail +- fetch a trail +- add and page records +- delete records in batch + +## Environment + +Set the following environment variables before running the node examples: + +| Name | Required | Description | +| --- | --- | --- | +| `IOTA_AUDIT_TRAIL_PKG_ID` | yes | Package ID of the deployed `audit_trail` Move package | +| `NETWORK_URL` | yes | RPC URL of the IOTA node | +| `NETWORK_NAME_FAUCET` | local/test networks | Faucet alias used by `@iota/iota-sdk` | + +## Run + +Install dependencies and build the package: + +```bash +npm install +npm run build +``` + +Run an example: + +```bash +IOTA_AUDIT_TRAIL_PKG_ID= NETWORK_URL=http://127.0.0.1:9000 npm run example:node -- 01_create_trail +``` + +Available examples: + +- `01_create_trail` +- `02_fetch_trail` +- `03_add_and_list_records` +- `04_delete_records_batch` diff --git a/bindings/wasm/audit_trails_wasm/examples/src/01_create_trail.ts b/bindings/wasm/audit_trails_wasm/examples/src/01_create_trail.ts new file mode 100644 index 00000000..21405e34 --- /dev/null +++ b/bindings/wasm/audit_trails_wasm/examples/src/01_create_trail.ts @@ -0,0 +1,21 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +import { strict as assert } from "assert"; +import { createTrailWithSeedRecord, getFundedClient } from "./util"; + +export async function createTrail(): Promise { + console.log("Creating an audit trail"); + + const client = await getFundedClient(); + const { output: trail, response } = await createTrailWithSeedRecord(client); + + console.log(`Created trail ${trail.id} with transaction ${response.digest}`); + console.log("Immutable metadata:", trail.immutableMetadata); + console.log("Updatable metadata:", trail.updatableMetadata); + console.log("Locking config:", trail.lockingConfig); + + assert.equal(trail.sequenceNumber, 1n); + assert.ok(trail.immutableMetadata); + assert.equal(trail.immutableMetadata?.name, "Example Audit Trail"); +} diff --git a/bindings/wasm/audit_trails_wasm/examples/src/02_fetch_trail.ts b/bindings/wasm/audit_trails_wasm/examples/src/02_fetch_trail.ts new file mode 100644 index 00000000..34479de5 --- /dev/null +++ b/bindings/wasm/audit_trails_wasm/examples/src/02_fetch_trail.ts @@ -0,0 +1,19 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +import { strict as assert } from "assert"; +import { createTrailWithSeedRecord, getFundedClient } from "./util"; + +export async function fetchTrail(): Promise { + console.log("Fetching an existing audit trail"); + + const client = await getFundedClient(); + const { output: createdTrail } = await createTrailWithSeedRecord(client); + + const fetchedTrail = await client.readOnly().trail(createdTrail.id).get(); + + console.log("Fetched trail:", fetchedTrail); + assert.equal(fetchedTrail.id, createdTrail.id); + assert.equal(fetchedTrail.sequenceNumber, createdTrail.sequenceNumber); + assert.equal(fetchedTrail.immutableMetadata?.name, createdTrail.immutableMetadata?.name); +} diff --git a/bindings/wasm/audit_trails_wasm/examples/src/03_add_and_list_records.ts b/bindings/wasm/audit_trails_wasm/examples/src/03_add_and_list_records.ts new file mode 100644 index 00000000..f122b845 --- /dev/null +++ b/bindings/wasm/audit_trails_wasm/examples/src/03_add_and_list_records.ts @@ -0,0 +1,37 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +import { strict as assert } from "assert"; +import { createTrailWithSeedRecord, getFundedClient, TEST_GAS_BUDGET } from "./util"; + +export async function addAndListRecords(): Promise { + console.log("Adding records and reading them back with pagination"); + + const client = await getFundedClient(); + const { output: trail } = await createTrailWithSeedRecord(client); + const records = client.trail(trail.id).records(); + + const addedString = await records + .addString("record 2", "second") + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + const addedBytes = await records + .addBytes(Uint8Array.from([1, 2, 3, 4]), "third") + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + + console.log("Added record sequence numbers:", addedString.output, addedBytes.output); + + const allRecords = await records.list(); + const firstPage = await records.listPage(undefined, 2); + const secondPage = await records.listPage(firstPage.nextCursor, 2); + + console.log("All records:", allRecords); + console.log("First page:", firstPage); + console.log("Second page:", secondPage); + + assert.equal(allRecords.length, 3); + assert.equal(firstPage.records.length, 2); + assert.equal(firstPage.hasNextPage, true); + assert.equal(secondPage.records.length, 1); +} diff --git a/bindings/wasm/audit_trails_wasm/examples/src/04_delete_records_batch.ts b/bindings/wasm/audit_trails_wasm/examples/src/04_delete_records_batch.ts new file mode 100644 index 00000000..da1900d1 --- /dev/null +++ b/bindings/wasm/audit_trails_wasm/examples/src/04_delete_records_batch.ts @@ -0,0 +1,26 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +import { strict as assert } from "assert"; +import { createTrailWithSeedRecord, getFundedClient, TEST_GAS_BUDGET } from "./util"; + +export async function deleteRecordsBatch(): Promise { + console.log("Deleting records in batch"); + + const client = await getFundedClient(); + const { output: trail } = await createTrailWithSeedRecord(client); + const records = client.trail(trail.id).records(); + + await records.addString("record 2", "second").withGasBudget(TEST_GAS_BUDGET).buildAndExecute(client); + await records.addString("record 3", "third").withGasBudget(TEST_GAS_BUDGET).buildAndExecute(client); + + const before = await records.recordCount(); + const deleted = await records.deleteBatch(BigInt(2)).withGasBudget(TEST_GAS_BUDGET).buildAndExecute(client); + const after = await records.recordCount(); + + console.log(`Deleted ${deleted.output} records. Count before=${before}, after=${after}`); + + assert.equal(before, 3); + assert.equal(deleted.output, 2); + assert.equal(after, 1); +} diff --git a/bindings/wasm/audit_trails_wasm/examples/src/main.ts b/bindings/wasm/audit_trails_wasm/examples/src/main.ts new file mode 100644 index 00000000..79e17271 --- /dev/null +++ b/bindings/wasm/audit_trails_wasm/examples/src/main.ts @@ -0,0 +1,31 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +import { createTrail } from "./01_create_trail"; +import { fetchTrail } from "./02_fetch_trail"; +import { addAndListRecords } from "./03_add_and_list_records"; +import { deleteRecordsBatch } from "./04_delete_records_batch"; + +export async function main(example?: string) { + const argument = example ?? process.argv?.[2]?.toLowerCase(); + if (!argument) { + throw new Error("Please specify an example name, e.g. '01_create_trail'"); + } + + switch (argument) { + case "01_create_trail": + return createTrail(); + case "02_fetch_trail": + return fetchTrail(); + case "03_add_and_list_records": + return addAndListRecords(); + case "04_delete_records_batch": + return deleteRecordsBatch(); + default: + throw new Error(`Unknown example name: '${argument}'`); + } +} + +main().catch((error) => { + console.error("Example error:", error); +}); diff --git a/bindings/wasm/audit_trails_wasm/examples/src/tests.ts b/bindings/wasm/audit_trails_wasm/examples/src/tests.ts new file mode 100644 index 00000000..cbcf6c85 --- /dev/null +++ b/bindings/wasm/audit_trails_wasm/examples/src/tests.ts @@ -0,0 +1,31 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +import { afterEach, describe, it } from "mocha"; + +import { createTrail } from "./01_create_trail"; +import { fetchTrail } from "./02_fetch_trail"; +import { addAndListRecords } from "./03_add_and_list_records"; +import { deleteRecordsBatch } from "./04_delete_records_batch"; + +describe("Audit trail wasm node examples", function() { + afterEach(() => { + console.log("\n----------------------------------------------------\n"); + }); + + it("creates a trail", async () => { + await createTrail(); + }); + + it("fetches a trail", async () => { + await fetchTrail(); + }); + + it("adds and lists records", async () => { + await addAndListRecords(); + }); + + it("deletes records in batch", async () => { + await deleteRecordsBatch(); + }); +}); diff --git a/bindings/wasm/audit_trails_wasm/examples/src/util.ts b/bindings/wasm/audit_trails_wasm/examples/src/util.ts new file mode 100644 index 00000000..9a699acb --- /dev/null +++ b/bindings/wasm/audit_trails_wasm/examples/src/util.ts @@ -0,0 +1,72 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +import { Ed25519KeypairSigner } from "@iota/iota-interaction-ts/node/test_utils"; +import { IotaClient } from "@iota/iota-sdk/client"; +import { getFaucetHost, requestIotaFromFaucetV0 } from "@iota/iota-sdk/faucet"; +import { Ed25519Keypair } from "@iota/iota-sdk/keypairs/ed25519"; +import { + AuditTrailClient, + AuditTrailClientReadOnly, + LockingConfig, + LockingWindow, + TimeLock, +} from "@iota/audit-trails/node"; + +export const IOTA_AUDIT_TRAIL_PKG_ID = globalThis?.process?.env?.IOTA_AUDIT_TRAIL_PKG_ID || ""; +export const NETWORK_NAME_FAUCET = globalThis?.process?.env?.NETWORK_NAME_FAUCET || "localnet"; +export const NETWORK_URL = globalThis?.process?.env?.NETWORK_URL || "http://127.0.0.1:9000"; +export const TEST_GAS_BUDGET = BigInt(50_000_000); + +if (!IOTA_AUDIT_TRAIL_PKG_ID) { + throw new Error("IOTA_AUDIT_TRAIL_PKG_ID env variable must be set to run the examples"); +} + +export async function requestFunds(address: string) { + await requestIotaFromFaucetV0({ + host: getFaucetHost(NETWORK_NAME_FAUCET), + recipient: address, + }); +} + +export async function getReadOnlyClient(): Promise { + const iotaClient = new IotaClient({ url: NETWORK_URL }); + return AuditTrailClientReadOnly.createWithPkgId(iotaClient, IOTA_AUDIT_TRAIL_PKG_ID); +} + +export async function getFundedClient(): Promise { + const readOnlyClient = await getReadOnlyClient(); + const keypair = Ed25519Keypair.generate(); + const signer = new Ed25519KeypairSigner(keypair); + const client = await AuditTrailClient.create(readOnlyClient, signer); + + await requestFunds(client.senderAddress()); + + const balance = await client.iotaClient().getBalance({ owner: client.senderAddress() }); + if (balance.totalBalance === "0") { + throw new Error("Balance is still 0 after faucet funding"); + } + + console.log(`Received gas from faucet: ${balance.totalBalance} for owner ${client.senderAddress()}`); + return client; +} + +export function defaultLockingConfig(): LockingConfig { + return new LockingConfig( + LockingWindow.withCountBased(BigInt(100)), + TimeLock.withNone(), + TimeLock.withNone(), + ); +} + +export async function createTrailWithSeedRecord(client: AuditTrailClient) { + return client + .createTrail() + .withTrailMetadata("Example Audit Trail", "WASM example trail") + .withUpdatableMetadata("seed metadata") + .withLockingConfig(defaultLockingConfig()) + .withInitialRecordString("seed record", "v1") + .finish() + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); +} diff --git a/bindings/wasm/audit_trails_wasm/examples/src/web-main.ts b/bindings/wasm/audit_trails_wasm/examples/src/web-main.ts new file mode 100644 index 00000000..79e17271 --- /dev/null +++ b/bindings/wasm/audit_trails_wasm/examples/src/web-main.ts @@ -0,0 +1,31 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +import { createTrail } from "./01_create_trail"; +import { fetchTrail } from "./02_fetch_trail"; +import { addAndListRecords } from "./03_add_and_list_records"; +import { deleteRecordsBatch } from "./04_delete_records_batch"; + +export async function main(example?: string) { + const argument = example ?? process.argv?.[2]?.toLowerCase(); + if (!argument) { + throw new Error("Please specify an example name, e.g. '01_create_trail'"); + } + + switch (argument) { + case "01_create_trail": + return createTrail(); + case "02_fetch_trail": + return fetchTrail(); + case "03_add_and_list_records": + return addAndListRecords(); + case "04_delete_records_batch": + return deleteRecordsBatch(); + default: + throw new Error(`Unknown example name: '${argument}'`); + } +} + +main().catch((error) => { + console.error("Example error:", error); +}); diff --git a/bindings/wasm/audit_trails_wasm/examples/tsconfig.node.json b/bindings/wasm/audit_trails_wasm/examples/tsconfig.node.json new file mode 100644 index 00000000..3250200f --- /dev/null +++ b/bindings/wasm/audit_trails_wasm/examples/tsconfig.node.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "es2020", + "baseUrl": "./", + "lib": [ + "ES6", + "dom" + ], + "esModuleInterop": true, + "module": "commonjs", + "moduleResolution": "node", + "skipLibCheck": true, + "paths": { + "@iota/audit-trails/node": [ + "../node" + ] + } + }, + "include": [ + "./src/**/*.ts" + ] +} diff --git a/bindings/wasm/audit_trails_wasm/examples/tsconfig.web.json b/bindings/wasm/audit_trails_wasm/examples/tsconfig.web.json new file mode 100644 index 00000000..b4f376cd --- /dev/null +++ b/bindings/wasm/audit_trails_wasm/examples/tsconfig.web.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "es2020", + "outDir": "./dist/web", + "baseUrl": "./", + "lib": [ + "ES6", + "dom" + ], + "esModuleInterop": true, + "moduleResolution": "node", + "skipLibCheck": true, + "paths": { + "@iota/audit-trails/node": [ + "../web" + ] + } + }, + "exclude": [ + "tests" + ] +} diff --git a/bindings/wasm/audit_trails_wasm/lib/index.ts b/bindings/wasm/audit_trails_wasm/lib/index.ts new file mode 100644 index 00000000..44e60299 --- /dev/null +++ b/bindings/wasm/audit_trails_wasm/lib/index.ts @@ -0,0 +1,5 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +export * from "@iota/iota-interaction-ts/transaction_internal"; +export * from "~audit_trails_wasm"; diff --git a/bindings/wasm/audit_trails_wasm/lib/tsconfig.json b/bindings/wasm/audit_trails_wasm/lib/tsconfig.json new file mode 100644 index 00000000..bdfee95b --- /dev/null +++ b/bindings/wasm/audit_trails_wasm/lib/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../tsconfig.node.json", + "compilerOptions": { + "baseUrl": "./", + "paths": { + "~audit_trails_wasm": [ + "../node/audit_trails_wasm", + "./audit_trails_wasm.js" + ], + "@iota/iota-interaction-ts/*": [ + "../node_modules/@iota/iota-interaction-ts/node/*", + "@iota/iota-interaction-ts/node/" + ], + "../lib": [ + "." + ] + }, + "outDir": "../node", + "declarationDir": "../node" + } +} diff --git a/bindings/wasm/audit_trails_wasm/lib/tsconfig.web.json b/bindings/wasm/audit_trails_wasm/lib/tsconfig.web.json new file mode 100644 index 00000000..c7c466a4 --- /dev/null +++ b/bindings/wasm/audit_trails_wasm/lib/tsconfig.web.json @@ -0,0 +1,22 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "baseUrl": "./", + "paths": { + "~audit_trails_wasm": [ + "../web/audit_trails_wasm", + "./audit_trails_wasm.js" + ], + "@iota/iota-interaction-ts/*": [ + "../node_modules/@iota/iota-interaction-ts/web/*", + "@iota/iota-interaction-ts/web/" + ], + "../lib": [ + "." + ] + }, + "outDir": "../web", + "declarationDir": "../web", + "module": "ES2022" + } +} diff --git a/bindings/wasm/audit_trails_wasm/package-lock.json b/bindings/wasm/audit_trails_wasm/package-lock.json new file mode 100644 index 00000000..1ecfb89c --- /dev/null +++ b/bindings/wasm/audit_trails_wasm/package-lock.json @@ -0,0 +1,2312 @@ +{ + "name": "@iota/audit-trails", + "version": "0.1.0-alpha", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@iota/audit-trails", + "version": "0.1.0-alpha", + "license": "Apache-2.0", + "dependencies": { + "@iota/iota-interaction-ts": "^0.12.0" + }, + "devDependencies": { + "@types/mocha": "^9.1.0", + "@types/node": "^22.0.0", + "dprint": "^0.33.0", + "mocha": "^9.2.0", + "rimraf": "^6.0.1", + "ts-mocha": "^9.0.2", + "ts-node": "^10.9.2", + "tsconfig-paths": "^4.1.0", + "typedoc": "^0.28.5", + "typedoc-plugin-markdown": "^4.4.1", + "typescript": "^5.7.3", + "wasm-opt": "^1.4.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@iota/iota-sdk": "^1.11.0" + } + }, + "node_modules/@0no-co/graphql.web": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@0no-co/graphql.web/-/graphql.web-1.2.0.tgz", + "integrity": "sha512-/1iHy9TTr63gE1YcR5idjx8UREz1s0kFhydf3bBLCXyqjhkIc6igAzTOx3zPifCwFR87tsh/4Pa9cNts6d2otw==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0" + }, + "peerDependenciesMeta": { + "graphql": { + "optional": true + } + } + }, + "node_modules/@0no-co/graphqlsp": { + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/@0no-co/graphqlsp/-/graphqlsp-1.15.2.tgz", + "integrity": "sha512-Ys031WnS3sTQQBtRTkQsYnw372OlW72ais4sp0oh2UMPRNyxxnq85zRfU4PIdoy9kWriysPT5BYAkgIxhbonFA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@gql.tada/internal": "^1.0.0", + "graphql": "^15.5.0 || ^16.0.0 || ^17.0.0" + }, + "peerDependencies": { + "graphql": "^15.5.0 || ^16.0.0 || ^17.0.0", + "typescript": "^5.0.0" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@gerrit0/mini-shiki": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@gerrit0/mini-shiki/-/mini-shiki-3.23.0.tgz", + "integrity": "sha512-bEMORlG0cqdjVyCEuU0cDQbORWX+kYCeo0kV1lbxF5bt4r7SID2l9bqsxJEM0zndaxpOUT7riCyIVEuqq/Ynxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/engine-oniguruma": "^3.23.0", + "@shikijs/langs": "^3.23.0", + "@shikijs/themes": "^3.23.0", + "@shikijs/types": "^3.23.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "node_modules/@gql.tada/cli-utils": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@gql.tada/cli-utils/-/cli-utils-1.7.2.tgz", + "integrity": "sha512-Qbc7hbLvCz6IliIJpJuKJa9p05b2Jona7ov7+qofCsMRxHRZE1kpAmZMvL8JCI4c0IagpIlWNaMizXEQUe8XjQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@0no-co/graphqlsp": "^1.12.13", + "@gql.tada/internal": "1.0.8", + "graphql": "^15.5.0 || ^16.0.0 || ^17.0.0" + }, + "peerDependencies": { + "@0no-co/graphqlsp": "^1.12.13", + "@gql.tada/svelte-support": "1.0.1", + "@gql.tada/vue-support": "1.0.1", + "graphql": "^15.5.0 || ^16.0.0 || ^17.0.0", + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "@gql.tada/svelte-support": { + "optional": true + }, + "@gql.tada/vue-support": { + "optional": true + } + } + }, + "node_modules/@gql.tada/internal": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@gql.tada/internal/-/internal-1.0.8.tgz", + "integrity": "sha512-XYdxJhtHC5WtZfdDqtKjcQ4d7R1s0d1rnlSs3OcBEUbYiPoJJfZU7tWsVXuv047Z6msvmr4ompJ7eLSK5Km57g==", + "license": "MIT", + "peer": true, + "dependencies": { + "@0no-co/graphql.web": "^1.0.5" + }, + "peerDependencies": { + "graphql": "^15.5.0 || ^16.0.0 || ^17.0.0", + "typescript": "^5.0.0" + } + }, + "node_modules/@graphql-typed-document-node/core": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", + "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@iota/bcs": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@iota/bcs/-/bcs-1.5.0.tgz", + "integrity": "sha512-/hv395YtUcRNLY00v7Cl2O+KvVUaUajg4OucZENgSE4Xu1ygUGsLD3dU5FixOUVOn7Abo+n7+KYr9PE/1dsvWg==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@scure/base": "^1.2.4" + } + }, + "node_modules/@iota/iota-interaction-ts": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@iota/iota-interaction-ts/-/iota-interaction-ts-0.12.0.tgz", + "integrity": "sha512-qGGn7DMpzDHCzdrvV4QWUXE1u/5UzX5Y7pWX9RNGkhlerD7gPk01abf4XjfmEhRkN3S2L7YBpnXK34LA6ZzC9w==", + "license": "Apache-2.0", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@iota/iota-sdk": "^1.11.0" + } + }, + "node_modules/@iota/iota-sdk": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@iota/iota-sdk/-/iota-sdk-1.11.0.tgz", + "integrity": "sha512-Fveg/4euheaBUzU1ybPyFGe7sSfLFUjLNHhPjNFUmSBOMR+l9q3LU1QdN2sLElcmgJZ+BLxAEmL8TZ0eX3Khpw==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@graphql-typed-document-node/core": "^3.2.0", + "@iota/bcs": "1.5.0", + "@noble/curves": "^1.4.2", + "@noble/hashes": "^1.4.0", + "@scure/base": "^1.2.4", + "@scure/bip32": "^1.4.0", + "@scure/bip39": "^1.3.0", + "bignumber.js": "^9.1.1", + "gql.tada": "^1.8.2", + "graphql": "^16.9.0", + "valibot": "^1.2.0" + }, + "engines": { + "node": ">=24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "peer": true, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/base": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", + "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz", + "integrity": "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@noble/curves": "~1.9.0", + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz", + "integrity": "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==", + "license": "MIT", + "peer": true, + "dependencies": { + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.23.0.tgz", + "integrity": "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "node_modules/@shikijs/langs": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.23.0.tgz", + "integrity": "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0" + } + }, + "node_modules/@shikijs/themes": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.23.0.tgz", + "integrity": "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0" + } + }, + "node_modules/@shikijs/types": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.23.0.tgz", + "integrity": "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@types/mocha": { + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-9.1.1.tgz", + "integrity": "sha512-Z61JK7DKDtdKTWwLeElSEBcWGRLY8g95ic5FoQqI9CMx0ns/Ghep3B4DfcEimiKMvtamNVULVNKEsiwV3aQmXw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", + "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@ungap/promise-all-settled": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@ungap/promise-all-settled/-/promise-all-settled-1.1.2.tgz", + "integrity": "sha512-sL/cEvJWAnClXw0wHk85/2L0G6Sj8UB0Ctc1TEMbKSsmpRosqhwj9gWgFRZSrBr2f9tiXISwNhCPmlfqUqyb9Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ansi-colors": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz", + "integrity": "sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/arrify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", + "integrity": "sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": "*" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browser-stdout": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", + "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", + "dev": true, + "license": "ISC" + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/chokidar": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", + "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ], + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.3.tgz", + "integrity": "sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/debug/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/decamelize": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz", + "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/diff": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.0.0.tgz", + "integrity": "sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dprint": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/dprint/-/dprint-0.33.0.tgz", + "integrity": "sha512-VploASP7wL1HAYe5xWZKRwp8gW5zTdcG3Tb60DASv6QLnGKsl+OS+bY7wsXFrS4UcIbUNujXdsNG5FxBfRJIQg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "yauzl": "=2.10.0" + }, + "bin": { + "dprint": "bin.js" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "dev": true, + "license": "BSD-3-Clause", + "bin": { + "flat": "cli.js" + } + }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/glob": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", + "integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/gql.tada": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/gql.tada/-/gql.tada-1.9.0.tgz", + "integrity": "sha512-1LMiA46dRs5oF7Qev6vMU32gmiNvM3+3nHoQZA9K9j2xQzH8xOAWnnJrLSbZOFHTSdFxqn86TL6beo1/7ja/aA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@0no-co/graphql.web": "^1.0.5", + "@0no-co/graphqlsp": "^1.12.13", + "@gql.tada/cli-utils": "1.7.2", + "@gql.tada/internal": "1.0.8" + }, + "bin": { + "gql-tada": "bin/cli.js", + "gql.tada": "bin/cli.js" + }, + "peerDependencies": { + "typescript": "^5.0.0" + } + }, + "node_modules/graphql": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.1.tgz", + "integrity": "sha512-gGgrVCoDKlIZ8fIqXBBb0pPKqDgki0Z/FSKNiQzSGj2uEYHr1tq5wmBegGwJx6QB5S5cM0khSBpi/JFHMCvsmQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/growl": { + "version": "1.10.5", + "resolved": "https://registry.npmjs.org/growl/-/growl-1.10.5.tgz", + "integrity": "sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.x" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/lunr": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz", + "integrity": "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/markdown-it": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", + "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.0", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "dev": true, + "license": "MIT" + }, + "node_modules/minimatch": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-4.2.1.tgz", + "integrity": "sha512-9Uq1ChtSZO+Mxa/CL1eGizn2vRn3MlLgzhT0Iz8zaY8NdvxvB0d5QdPFmCKf7JKA9Lerx5vRrnwO03jsSfGG9g==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mocha": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-9.2.2.tgz", + "integrity": "sha512-L6XC3EdwT6YrIk0yXpavvLkn8h+EU+Y5UcCHKECyMbdUIxyMuZj4bX4U9e1nvnvUUvQVsV2VHQr5zLdcUkhW/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ungap/promise-all-settled": "1.1.2", + "ansi-colors": "4.1.1", + "browser-stdout": "1.3.1", + "chokidar": "3.5.3", + "debug": "4.3.3", + "diff": "5.0.0", + "escape-string-regexp": "4.0.0", + "find-up": "5.0.0", + "glob": "7.2.0", + "growl": "1.10.5", + "he": "1.2.0", + "js-yaml": "4.1.0", + "log-symbols": "4.1.0", + "minimatch": "4.2.1", + "ms": "2.1.3", + "nanoid": "3.3.1", + "serialize-javascript": "6.0.0", + "strip-json-comments": "3.1.1", + "supports-color": "8.1.1", + "which": "2.0.2", + "workerpool": "6.2.0", + "yargs": "16.2.0", + "yargs-parser": "20.2.4", + "yargs-unparser": "2.0.0" + }, + "bin": { + "_mocha": "bin/_mocha", + "mocha": "bin/mocha" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mochajs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.1.tgz", + "integrity": "sha512-n6Vs/3KGyxPQd6uO0eH4Bv0ojGSUvuLlIHtC3Y0kEO23YRge8H9x1GCzLn28YX0H66pMkxuaeESFq4tKISKwdw==", + "dev": true, + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", + "integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "glob": "^13.0.3", + "package-json-from-dist": "^1.0.1" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/rimraf/node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/serialize-javascript": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.0.tgz", + "integrity": "sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/ts-mocha": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/ts-mocha/-/ts-mocha-9.0.2.tgz", + "integrity": "sha512-WyQjvnzwrrubl0JT7EC1yWmNpcsU3fOuBFfdps30zbmFBgKniSaSOyZMZx+Wq7kytUs5CY+pEbSYEbGfIKnXTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ts-node": "7.0.1" + }, + "bin": { + "ts-mocha": "bin/ts-mocha" + }, + "engines": { + "node": ">= 6.X.X" + }, + "optionalDependencies": { + "tsconfig-paths": "^3.5.0" + }, + "peerDependencies": { + "mocha": "^3.X.X || ^4.X.X || ^5.X.X || ^6.X.X || ^7.X.X || ^8.X.X || ^9.X.X" + } + }, + "node_modules/ts-mocha/node_modules/diff": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/diff/-/diff-3.5.1.tgz", + "integrity": "sha512-Z3u54A8qGyqFOSr2pk0ijYs8mOE9Qz8kTvtKeBI+upoG9j04Sq+oI7W8zAJiQybDcESET8/uIdHzs0p3k4fZlw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/ts-mocha/node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/ts-mocha/node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/ts-mocha/node_modules/ts-node": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-7.0.1.tgz", + "integrity": "sha512-BVwVbPJRspzNh2yfslyT1PSbl5uIk03EZlb493RKHN4qej/D06n1cEhjlOJG69oFsE7OT8XjpTUcYf6pKTLMhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "arrify": "^1.0.0", + "buffer-from": "^1.1.0", + "diff": "^3.1.0", + "make-error": "^1.1.1", + "minimist": "^1.2.0", + "mkdirp": "^0.5.1", + "source-map-support": "^0.5.6", + "yn": "^2.0.0" + }, + "bin": { + "ts-node": "dist/bin.js" + }, + "engines": { + "node": ">=4.2.0" + } + }, + "node_modules/ts-mocha/node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/ts-mocha/node_modules/yn": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/yn/-/yn-2.0.0.tgz", + "integrity": "sha512-uTv8J/wiWTgUTg+9vLTi//leUl5vDQS6uii/emeTb2ssY7vl6QWf2fFbIIGjnhjvbdKlU0ed7QPgY1htTC86jQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/ts-node/node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/tsconfig-paths": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", + "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "json5": "^2.2.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/typedoc": { + "version": "0.28.17", + "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.28.17.tgz", + "integrity": "sha512-ZkJ2G7mZrbxrKxinTQMjFqsCoYY6a5Luwv2GKbTnBCEgV2ihYm5CflA9JnJAwH0pZWavqfYxmDkFHPt4yx2oDQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@gerrit0/mini-shiki": "^3.17.0", + "lunr": "^2.3.9", + "markdown-it": "^14.1.0", + "minimatch": "^9.0.5", + "yaml": "^2.8.1" + }, + "bin": { + "typedoc": "bin/typedoc" + }, + "engines": { + "node": ">= 18", + "pnpm": ">= 10" + }, + "peerDependencies": { + "typescript": "5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x" + } + }, + "node_modules/typedoc-plugin-markdown": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/typedoc-plugin-markdown/-/typedoc-plugin-markdown-4.10.0.tgz", + "integrity": "sha512-psrg8Rtnv4HPWCsoxId+MzEN8TVK5jeKCnTbnGAbTBqcDapR9hM41bJT/9eAyKn9C2MDG9Qjh3MkltAYuLDoXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "typedoc": "0.28.x" + } + }, + "node_modules/typedoc/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/typedoc/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/valibot": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.2.0.tgz", + "integrity": "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/wasm-opt": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/wasm-opt/-/wasm-opt-1.4.0.tgz", + "integrity": "sha512-wIsxxp0/FOSphokH4VOONy1zPkVREQfALN+/JTvJPK8gFSKbsmrcfECu2hT7OowqPfb4WEMSMceHgNL0ipFRyw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-fetch": "^2.6.9", + "tar": "^6.1.13" + }, + "bin": { + "wasm-opt": "bin/wasm-opt.js" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/workerpool": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.2.0.tgz", + "integrity": "sha512-Rsk5qQHJ9eowMH28Jwhe8HEbmdYDX4lwoMWshiCXugjtHqMD9ZbiqSDLxcsfdqsETPzVUtX5s1Z5kStiIM6l4A==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs-parser": { + "version": "20.2.4", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.4.tgz", + "integrity": "sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs-unparser": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz", + "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "camelcase": "^6.0.0", + "decamelize": "^4.0.0", + "flat": "^5.0.2", + "is-plain-obj": "^2.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/bindings/wasm/audit_trails_wasm/package.json b/bindings/wasm/audit_trails_wasm/package.json new file mode 100644 index 00000000..7ae5c887 --- /dev/null +++ b/bindings/wasm/audit_trails_wasm/package.json @@ -0,0 +1,63 @@ +{ + "name": "@iota/audit-trails", + "author": "IOTA Foundation ", + "description": "WASM bindings for IOTA Audit Trails. To be used in JavaScript/TypeScript.", + "homepage": "https://www.iota.org", + "version": "0.1.0-alpha", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "git+https://github.com/iotaledger/notarization.git" + }, + "directories": { + "example": "examples" + }, + "scripts": { + "build:src": "cargo build --lib --release --target wasm32-unknown-unknown --target-dir ../target", + "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/audit_trails_wasm.wasm --typescript --weak-refs --target nodejs --out-dir node && node ../build/node audit_trails_wasm && tsc --project ./lib/tsconfig.json && node ../build/replace_paths ./lib/tsconfig.json node audit_trails_wasm", + "prebundle:web": "rimraf web", + "bundle:web": "wasm-bindgen ../target/wasm32-unknown-unknown/release/audit_trails_wasm.wasm --typescript --target web --out-dir web && node ../build/web audit_trails_wasm && tsc --project ./lib/tsconfig.web.json && node ../build/replace_paths ./lib/tsconfig.web.json web audit_trails_wasm", + "build:nodejs": "npm run build:src:nodejs && npm run bundle:nodejs && wasm-opt -O node/audit_trails_wasm_bg.wasm -o node/audit_trails_wasm_bg.wasm", + "build:web": "npm run build:src && npm run bundle:web && wasm-opt -O web/audit_trails_wasm_bg.wasm -o web/audit_trails_wasm_bg.wasm", + "build:docs": "typedoc && npm run fix_docs", + "build:examples:web": "tsc --project ./examples/tsconfig.web.json || node ../build/replace_paths ./examples/tsconfig.web.json dist/web audit_trails_wasm/examples resolve", + "build": "npm run build:web && npm run build:nodejs && npm run build:docs", + "example:node": "ts-node --project ./examples/tsconfig.node.json -r tsconfig-paths/register ./examples/src/main.ts", + "test": "npm run test:node", + "test:node": "ts-mocha -r tsconfig-paths/register -p ./examples/tsconfig.node.json ./examples/src/tests.ts --parallel --jobs 4 --retries 3 --timeout 180000 --exit", + "fmt": "dprint fmt", + "fix_docs": "find ./docs/wasm/ -type f -name '*.md' -exec sed -E -i.bak -e 's/(\\.md?#([^#]*)?)#/\\1/' {} ';' -exec rm {}.bak ';'" + }, + "publishConfig": { + "access": "public" + }, + "files": [ + "web/*", + "node/*" + ], + "devDependencies": { + "@types/mocha": "^9.1.0", + "@types/node": "^22.0.0", + "dprint": "^0.33.0", + "mocha": "^9.2.0", + "rimraf": "^6.0.1", + "ts-mocha": "^9.0.2", + "ts-node": "^10.9.2", + "tsconfig-paths": "^4.1.0", + "typedoc": "^0.28.5", + "typedoc-plugin-markdown": "^4.4.1", + "typescript": "^5.7.3", + "wasm-opt": "^1.4.0" + }, + "dependencies": { + "@iota/iota-interaction-ts": "^0.12.0" + }, + "peerDependencies": { + "@iota/iota-sdk": "^1.11.0" + }, + "engines": { + "node": ">=20" + } +} diff --git a/bindings/wasm/audit_trails_wasm/rust-toolchain.toml b/bindings/wasm/audit_trails_wasm/rust-toolchain.toml new file mode 100644 index 00000000..825d39b5 --- /dev/null +++ b/bindings/wasm/audit_trails_wasm/rust-toolchain.toml @@ -0,0 +1,5 @@ +[toolchain] +channel = "stable" +components = ["rustfmt"] +targets = ["wasm32-unknown-unknown"] +profile = "minimal" diff --git a/bindings/wasm/audit_trails_wasm/src/builder.rs b/bindings/wasm/audit_trails_wasm/src/builder.rs new file mode 100644 index 00000000..d9fd5042 --- /dev/null +++ b/bindings/wasm/audit_trails_wasm/src/builder.rs @@ -0,0 +1,54 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use audit_trails::core::builder::AuditTrailBuilder; +use iota_interaction_ts::wasm_error::Result; +use product_common::bindings::transaction::WasmTransactionBuilder; +use product_common::bindings::utils::{into_transaction_builder, parse_wasm_iota_address}; +use product_common::bindings::WasmIotaAddress; +use wasm_bindgen::prelude::*; + +use crate::trail::WasmCreateTrail; +use crate::types::WasmLockingConfig; + +#[wasm_bindgen(js_name = AuditTrailBuilder, inspectable)] +pub struct WasmAuditTrailBuilder(pub(crate) AuditTrailBuilder); + +#[wasm_bindgen(js_class = AuditTrailBuilder)] +impl WasmAuditTrailBuilder { + #[wasm_bindgen(js_name = withInitialRecordString)] + pub fn with_initial_record_string(self, data: String, metadata: Option) -> Self { + Self(self.0.with_initial_record(data, metadata)) + } + + #[wasm_bindgen(js_name = withInitialRecordBytes)] + pub fn with_initial_record_bytes(self, data: js_sys::Uint8Array, metadata: Option) -> Self { + Self(self.0.with_initial_record(data.to_vec(), metadata)) + } + + #[wasm_bindgen(js_name = withTrailMetadata)] + pub fn with_trail_metadata(self, name: String, description: Option) -> Self { + Self(self.0.with_trail_metadata_parts(name, description)) + } + + #[wasm_bindgen(js_name = withUpdatableMetadata)] + pub fn with_updatable_metadata(self, metadata: String) -> Self { + Self(self.0.with_updatable_metadata(metadata)) + } + + #[wasm_bindgen(js_name = withLockingConfig)] + pub fn with_locking_config(self, config: WasmLockingConfig) -> Self { + Self(self.0.with_locking_config(config.into())) + } + + #[wasm_bindgen(js_name = withAdmin)] + pub fn with_admin(self, admin: WasmIotaAddress) -> Result { + let admin = parse_wasm_iota_address(&admin)?; + Ok(Self(self.0.with_admin(admin))) + } + + #[wasm_bindgen(unchecked_return_type = "TransactionBuilder")] + pub fn finish(self) -> Result { + Ok(into_transaction_builder(WasmCreateTrail::new(self))) + } +} diff --git a/bindings/wasm/audit_trails_wasm/src/client.rs b/bindings/wasm/audit_trails_wasm/src/client.rs new file mode 100644 index 00000000..7ba632b7 --- /dev/null +++ b/bindings/wasm/audit_trails_wasm/src/client.rs @@ -0,0 +1,83 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use iota_interaction_ts::bindings::{WasmIotaClient, WasmPublicKey, WasmTransactionSigner}; +use iota_interaction_ts::wasm_error::Result; +use product_common::bindings::WasmObjectID; +use product_common::core_client::{CoreClient, CoreClientReadOnly}; +use wasm_bindgen::prelude::*; + +use crate::builder::WasmAuditTrailBuilder; +use crate::client_read_only::WasmAuditTrailClientReadOnly; +use crate::trail_handle::WasmAuditTrailHandle; +use crate::audit_trails_wasm_result; + +#[derive(Clone)] +#[wasm_bindgen(js_name = AuditTrailClient)] +pub struct WasmAuditTrailClient(pub(crate) audit_trails::AuditTrailClient); + +#[wasm_bindgen(js_class = AuditTrailClient)] +impl WasmAuditTrailClient { + #[wasm_bindgen(js_name = create)] + pub async fn new( + client: WasmAuditTrailClientReadOnly, + signer: WasmTransactionSigner, + ) -> Result { + let client = audit_trails_wasm_result(audit_trails::AuditTrailClient::new(client.0, signer).await)?; + Ok(Self(client)) + } + + #[wasm_bindgen(js_name = senderPublicKey)] + pub fn sender_public_key(&self) -> Result { + self.0.public_key().try_into() + } + + #[wasm_bindgen(js_name = senderAddress)] + pub fn sender_address(&self) -> String { + self.0.address().to_string() + } + + #[wasm_bindgen] + pub fn network(&self) -> String { + self.0.network().to_string() + } + + #[wasm_bindgen(js_name = packageId)] + pub fn package_id(&self) -> String { + self.0.package_id().to_string() + } + + #[wasm_bindgen(js_name = packageHistory)] + pub fn package_history(&self) -> Vec { + self.0 + .package_history() + .into_iter() + .map(|pkg_id| pkg_id.to_string()) + .collect() + } + + #[wasm_bindgen(js_name = iotaClient)] + pub fn iota_client(&self) -> WasmIotaClient { + self.0.read_only().iota_client().clone().into_inner() + } + + #[wasm_bindgen] + pub fn signer(&self) -> WasmTransactionSigner { + self.0.signer().clone() + } + + #[wasm_bindgen(js_name = readOnly)] + pub fn read_only(&self) -> WasmAuditTrailClientReadOnly { + WasmAuditTrailClientReadOnly(self.0.read_only().clone()) + } + + #[wasm_bindgen(js_name = createTrail)] + pub fn create_trail(&self) -> WasmAuditTrailBuilder { + WasmAuditTrailBuilder(self.0.create_trail()) + } + + pub fn trail(&self, trail_id: WasmObjectID) -> Result { + let trail_id = product_common::bindings::utils::parse_wasm_object_id(&trail_id)?; + Ok(WasmAuditTrailHandle::from_full(self.0.clone(), trail_id)) + } +} diff --git a/bindings/wasm/audit_trails_wasm/src/client_read_only.rs b/bindings/wasm/audit_trails_wasm/src/client_read_only.rs new file mode 100644 index 00000000..9c185078 --- /dev/null +++ b/bindings/wasm/audit_trails_wasm/src/client_read_only.rs @@ -0,0 +1,70 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use iota_interaction_ts::bindings::WasmIotaClient; +use iota_interaction_ts::wasm_error::Result; +use product_common::bindings::utils::parse_wasm_object_id; +use product_common::bindings::WasmObjectID; +use product_common::core_client::CoreClientReadOnly; +use wasm_bindgen::prelude::*; + +use crate::trail_handle::WasmAuditTrailHandle; +use crate::audit_trails_wasm_result; + +#[derive(Clone)] +#[wasm_bindgen(js_name = AuditTrailClientReadOnly)] +pub struct WasmAuditTrailClientReadOnly(pub(crate) audit_trails::AuditTrailClientReadOnly); + +#[wasm_bindgen(js_class = AuditTrailClientReadOnly)] +impl WasmAuditTrailClientReadOnly { + #[wasm_bindgen(js_name = create)] + pub async fn new(iota_client: WasmIotaClient) -> Result { + let client = audit_trails_wasm_result(audit_trails::AuditTrailClientReadOnly::new(iota_client).await)?; + Ok(Self(client)) + } + + #[wasm_bindgen(js_name = createWithPkgId)] + pub async fn new_with_pkg_id( + iota_client: WasmIotaClient, + package_id: WasmObjectID, + ) -> Result { + let package_id = parse_wasm_object_id(&package_id)?; + let client = + audit_trails_wasm_result(audit_trails::AuditTrailClientReadOnly::new_with_pkg_id(iota_client, package_id).await)?; + Ok(Self(client)) + } + + #[wasm_bindgen(js_name = packageId)] + pub fn package_id(&self) -> String { + self.0.package_id().to_string() + } + + #[wasm_bindgen(js_name = packageHistory)] + pub fn package_history(&self) -> Vec { + self.0 + .package_history() + .into_iter() + .map(|pkg_id| pkg_id.to_string()) + .collect() + } + + #[wasm_bindgen] + pub fn network(&self) -> String { + self.0.network().to_string() + } + + #[wasm_bindgen(js_name = chainId)] + pub fn chain_id(&self) -> String { + self.0.chain_id().to_string() + } + + #[wasm_bindgen(js_name = iotaClient)] + pub fn iota_client(&self) -> WasmIotaClient { + self.0.iota_client().clone().into_inner() + } + + pub fn trail(&self, trail_id: WasmObjectID) -> Result { + let trail_id = parse_wasm_object_id(&trail_id)?; + Ok(WasmAuditTrailHandle::from_read_only(self.0.clone(), trail_id)) + } +} diff --git a/bindings/wasm/audit_trails_wasm/src/lib.rs b/bindings/wasm/audit_trails_wasm/src/lib.rs new file mode 100644 index 00000000..12350067 --- /dev/null +++ b/bindings/wasm/audit_trails_wasm/src/lib.rs @@ -0,0 +1,54 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +#![allow(deprecated)] +#![allow(clippy::upper_case_acronyms)] +#![allow(clippy::drop_non_drop)] +#![allow(clippy::unused_unit)] +#![allow(clippy::await_holding_refcell_ref)] + +use std::borrow::Cow; + +use iota_interaction_ts::wasm_error::{Result, WasmError}; +use wasm_bindgen::JsValue; +use wasm_bindgen::prelude::*; + +mod trail; +pub(crate) mod builder; +pub(crate) mod client; +pub(crate) mod client_read_only; +pub(crate) mod trail_handle; +pub(crate) mod trail_records; +pub(crate) mod types; + +pub use product_common::bindings::*; + +#[wasm_bindgen(start)] +pub fn start() -> std::result::Result<(), JsValue> { + console_error_panic_hook::set_once(); + Ok(()) +} + +#[wasm_bindgen(typescript_custom_section)] +const CUSTOM_IMPORTS: &str = r#" +import { + Transaction, + TransactionOutput, + TransactionBuilder, + CoreClient, + CoreClientReadOnly +} from '../lib/index'; +"#; + +pub(crate) fn audit_trails_wasm_error(error: audit_trails::error::Error) -> JsValue { + JsValue::from(WasmError { + name: Cow::Borrowed("audit_trails::Error"), + message: Cow::Owned(error.to_string()), + }) +} + +pub(crate) fn audit_trails_wasm_result( + result: std::result::Result, +) -> Result { + result.map_err(audit_trails_wasm_error) +} diff --git a/bindings/wasm/audit_trails_wasm/src/trail.rs b/bindings/wasm/audit_trails_wasm/src/trail.rs new file mode 100644 index 00000000..a87e0881 --- /dev/null +++ b/bindings/wasm/audit_trails_wasm/src/trail.rs @@ -0,0 +1,242 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use audit_trails::core::create::{CreateTrail, TrailCreated}; +use audit_trails::core::records::{AddRecord, DeleteRecord, DeleteRecordsBatch}; +use audit_trails::core::trail::UpdateMetadata; +use audit_trails::core::types::{OnChainAuditTrail, RecordAdded, RecordDeleted}; +use iota_interaction_ts::bindings::{WasmIotaTransactionBlockEffects, WasmIotaTransactionBlockEvents}; +use iota_interaction_ts::core_client::WasmCoreClientReadOnly; +use iota_interaction_ts::wasm_error::{Result, WasmResult}; +use js_sys::Object; +use product_common::bindings::core_client::WasmManagedCoreClientReadOnly; +use product_common::transaction::transaction_builder::Transaction; +use wasm_bindgen::prelude::*; + +use crate::builder::WasmAuditTrailBuilder; +use crate::types::{WasmEmpty, WasmImmutableMetadata, WasmLockingConfig}; +use crate::audit_trails_wasm_result; + +#[wasm_bindgen(js_name = OnChainAuditTrail, inspectable)] +#[derive(Clone)] +pub struct WasmOnChainAuditTrail(pub(crate) OnChainAuditTrail); + +#[wasm_bindgen(js_class = OnChainAuditTrail)] +impl WasmOnChainAuditTrail { + pub(crate) fn new(trail: OnChainAuditTrail) -> Self { + Self(trail) + } + + #[wasm_bindgen(getter)] + pub fn id(&self) -> String { + self.0.id.id.to_string() + } + + #[wasm_bindgen(getter)] + pub fn creator(&self) -> String { + self.0.creator.to_string() + } + + #[wasm_bindgen(js_name = createdAt, getter)] + pub fn created_at(&self) -> u64 { + self.0.created_at + } + + #[wasm_bindgen(js_name = sequenceNumber, getter)] + pub fn sequence_number(&self) -> u64 { + self.0.sequence_number + } + + #[wasm_bindgen(js_name = lockingConfig, getter)] + pub fn locking_config(&self) -> WasmLockingConfig { + self.0.locking_config.clone().into() + } + + #[wasm_bindgen(js_name = immutableMetadata, getter)] + pub fn immutable_metadata(&self) -> Option { + self.0.immutable_metadata.clone().map(Into::into) + } + + #[wasm_bindgen(js_name = updatableMetadata, getter)] + pub fn updatable_metadata(&self) -> Option { + self.0.updatable_metadata.clone() + } + + #[wasm_bindgen(getter)] + pub fn version(&self) -> u64 { + self.0.version + } +} + +impl From for WasmOnChainAuditTrail { + fn from(value: OnChainAuditTrail) -> Self { + Self::new(value) + } +} + +async fn apply_trail_created( + tx: CreateTrail, + wasm_effects: &WasmIotaTransactionBlockEffects, + wasm_events: &WasmIotaTransactionBlockEvents, + client: &WasmCoreClientReadOnly, +) -> Result { + let managed_client = WasmManagedCoreClientReadOnly::from_wasm(client)?; + let mut effects = wasm_effects.clone().into(); + let mut events = wasm_events.clone().into(); + let created = tx.apply_with_events(&mut effects, &mut events, &managed_client).await; + + let rem_wasm_effects = WasmIotaTransactionBlockEffects::from(&effects); + Object::assign(wasm_effects, &rem_wasm_effects); + let rem_wasm_events = WasmIotaTransactionBlockEvents::from(&events); + Object::assign(wasm_events, &rem_wasm_events); + + let created: TrailCreated = audit_trails_wasm_result(created)?; + let trail = audit_trails_wasm_result(created.fetch_audit_trail(&managed_client).await)?; + Ok(trail.into()) +} + +async fn build_audit_trail_transaction(tx: &T, client: &WasmCoreClientReadOnly) -> Result> +where + T: Transaction, +{ + let managed_client = WasmManagedCoreClientReadOnly::from_wasm(client)?; + let pt = audit_trails_wasm_result(tx.build_programmable_transaction(&managed_client).await)?; + bcs::to_bytes(&pt).wasm_result() +} + +async fn apply_audit_trail_with_events( + tx: T, + wasm_effects: &WasmIotaTransactionBlockEffects, + wasm_events: &WasmIotaTransactionBlockEvents, + client: &WasmCoreClientReadOnly, +) -> Result +where + T: Transaction, + O: From<::Output>, +{ + let managed_client = WasmManagedCoreClientReadOnly::from_wasm(client)?; + let mut effects = wasm_effects.clone().into(); + let mut events = wasm_events.clone().into(); + let output = tx.apply_with_events(&mut effects, &mut events, &managed_client).await; + + let rem_wasm_effects = WasmIotaTransactionBlockEffects::from(&effects); + Object::assign(wasm_effects, &rem_wasm_effects); + let rem_wasm_events = WasmIotaTransactionBlockEvents::from(&events); + Object::assign(wasm_events, &rem_wasm_events); + + audit_trails_wasm_result(output).map(Into::into) +} + +#[wasm_bindgen(js_name = CreateTrail, inspectable)] +pub struct WasmCreateTrail(pub(crate) CreateTrail); + +#[wasm_bindgen(js_class = CreateTrail)] +impl WasmCreateTrail { + #[wasm_bindgen(constructor)] + pub fn new(builder: WasmAuditTrailBuilder) -> Self { + Self(CreateTrail::new(builder.0)) + } + + #[wasm_bindgen(js_name = buildProgrammableTransaction)] + pub async fn build_programmable_transaction(&self, client: &WasmCoreClientReadOnly) -> Result> { + build_audit_trail_transaction(&self.0, client).await + } + + #[wasm_bindgen(js_name = applyWithEvents)] + pub async fn apply_with_events( + self, + wasm_effects: &WasmIotaTransactionBlockEffects, + wasm_events: &WasmIotaTransactionBlockEvents, + client: &WasmCoreClientReadOnly, + ) -> Result { + apply_trail_created(self.0, wasm_effects, wasm_events, client).await + } +} + +#[wasm_bindgen(js_name = UpdateMetadata, inspectable)] +pub struct WasmUpdateMetadata(pub(crate) UpdateMetadata); + +#[wasm_bindgen(js_class = UpdateMetadata)] +impl WasmUpdateMetadata { + #[wasm_bindgen(js_name = buildProgrammableTransaction)] + pub async fn build_programmable_transaction(&self, client: &WasmCoreClientReadOnly) -> Result> { + build_audit_trail_transaction(&self.0, client).await + } + + #[wasm_bindgen(js_name = applyWithEvents)] + pub async fn apply_with_events( + self, + wasm_effects: &WasmIotaTransactionBlockEffects, + wasm_events: &WasmIotaTransactionBlockEvents, + client: &WasmCoreClientReadOnly, + ) -> Result { + apply_audit_trail_with_events(self.0, wasm_effects, wasm_events, client).await + } +} + +#[wasm_bindgen(js_name = AddRecord, inspectable)] +pub struct WasmAddRecord(pub(crate) AddRecord); + +#[wasm_bindgen(js_class = AddRecord)] +impl WasmAddRecord { + #[wasm_bindgen(js_name = buildProgrammableTransaction)] + pub async fn build_programmable_transaction(&self, client: &WasmCoreClientReadOnly) -> Result> { + build_audit_trail_transaction(&self.0, client).await + } + + #[wasm_bindgen(js_name = applyWithEvents)] + pub async fn apply_with_events( + self, + wasm_effects: &WasmIotaTransactionBlockEffects, + wasm_events: &WasmIotaTransactionBlockEvents, + client: &WasmCoreClientReadOnly, + ) -> Result { + let added: RecordAdded = + apply_audit_trail_with_events(self.0, wasm_effects, wasm_events, client).await?; + Ok(added.sequence_number) + } +} + +#[wasm_bindgen(js_name = DeleteRecord, inspectable)] +pub struct WasmDeleteRecord(pub(crate) DeleteRecord); + +#[wasm_bindgen(js_class = DeleteRecord)] +impl WasmDeleteRecord { + #[wasm_bindgen(js_name = buildProgrammableTransaction)] + pub async fn build_programmable_transaction(&self, client: &WasmCoreClientReadOnly) -> Result> { + build_audit_trail_transaction(&self.0, client).await + } + + #[wasm_bindgen(js_name = applyWithEvents)] + pub async fn apply_with_events( + self, + wasm_effects: &WasmIotaTransactionBlockEffects, + wasm_events: &WasmIotaTransactionBlockEvents, + client: &WasmCoreClientReadOnly, + ) -> Result { + let deleted: RecordDeleted = + apply_audit_trail_with_events(self.0, wasm_effects, wasm_events, client).await?; + Ok(deleted.sequence_number) + } +} + +#[wasm_bindgen(js_name = DeleteRecordsBatch, inspectable)] +pub struct WasmDeleteRecordsBatch(pub(crate) DeleteRecordsBatch); + +#[wasm_bindgen(js_class = DeleteRecordsBatch)] +impl WasmDeleteRecordsBatch { + #[wasm_bindgen(js_name = buildProgrammableTransaction)] + pub async fn build_programmable_transaction(&self, client: &WasmCoreClientReadOnly) -> Result> { + build_audit_trail_transaction(&self.0, client).await + } + + #[wasm_bindgen(js_name = applyWithEvents)] + pub async fn apply_with_events( + self, + wasm_effects: &WasmIotaTransactionBlockEffects, + wasm_events: &WasmIotaTransactionBlockEvents, + client: &WasmCoreClientReadOnly, + ) -> Result { + apply_audit_trail_with_events(self.0, wasm_effects, wasm_events, client).await + } +} diff --git a/bindings/wasm/audit_trails_wasm/src/trail_handle.rs b/bindings/wasm/audit_trails_wasm/src/trail_handle.rs new file mode 100644 index 00000000..f78eaed6 --- /dev/null +++ b/bindings/wasm/audit_trails_wasm/src/trail_handle.rs @@ -0,0 +1,74 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use anyhow::anyhow; +use iota_interaction::types::base_types::ObjectID; +use iota_interaction_ts::bindings::WasmTransactionSigner; +use iota_interaction_ts::wasm_error::{wasm_error, Result}; +use product_common::bindings::transaction::WasmTransactionBuilder; +use product_common::bindings::utils::into_transaction_builder; +use wasm_bindgen::prelude::*; + +use crate::trail::{WasmOnChainAuditTrail, WasmUpdateMetadata}; +use crate::trail_records::WasmTrailRecords; +use crate::audit_trails_wasm_result; + +#[derive(Clone)] +#[wasm_bindgen(js_name = AuditTrailHandle, inspectable)] +pub struct WasmAuditTrailHandle { + pub(crate) read_only: audit_trails::AuditTrailClientReadOnly, + pub(crate) full: Option>, + pub(crate) trail_id: ObjectID, +} + +impl WasmAuditTrailHandle { + pub(crate) fn from_read_only(read_only: audit_trails::AuditTrailClientReadOnly, trail_id: ObjectID) -> Self { + Self { + read_only, + full: None, + trail_id, + } + } + + pub(crate) fn from_full(full: audit_trails::AuditTrailClient, trail_id: ObjectID) -> Self { + Self { + read_only: full.read_only().clone(), + full: Some(full), + trail_id, + } + } + + fn full_client(&self) -> Result<&audit_trails::AuditTrailClient> { + self.full.as_ref().ok_or_else(|| { + wasm_error(anyhow!( + "AuditTrailHandle was created from a read-only client; this operation requires AuditTrailClient" + )) + }) + } +} + +#[wasm_bindgen(js_class = AuditTrailHandle)] +impl WasmAuditTrailHandle { + pub async fn get(&self) -> Result { + let trail = audit_trails_wasm_result(self.read_only.trail(self.trail_id).get().await)?; + Ok(trail.into()) + } + + #[wasm_bindgen(js_name = updateMetadata, unchecked_return_type = "TransactionBuilder")] + pub fn update_metadata(&self, metadata: Option) -> Result { + let tx = self + .full_client()? + .trail(self.trail_id) + .update_metadata(metadata) + .into_inner(); + Ok(into_transaction_builder(WasmUpdateMetadata(tx))) + } + + pub fn records(&self) -> WasmTrailRecords { + WasmTrailRecords { + read_only: self.read_only.clone(), + full: self.full.clone(), + trail_id: self.trail_id, + } + } +} diff --git a/bindings/wasm/audit_trails_wasm/src/trail_records.rs b/bindings/wasm/audit_trails_wasm/src/trail_records.rs new file mode 100644 index 00000000..d9239c26 --- /dev/null +++ b/bindings/wasm/audit_trails_wasm/src/trail_records.rs @@ -0,0 +1,115 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use anyhow::anyhow; +use iota_interaction::types::base_types::ObjectID; +use iota_interaction_ts::bindings::WasmTransactionSigner; +use iota_interaction_ts::wasm_error::{wasm_error, Result}; +use product_common::bindings::transaction::WasmTransactionBuilder; +use product_common::bindings::utils::into_transaction_builder; +use wasm_bindgen::prelude::*; + +use crate::trail::{WasmAddRecord, WasmDeleteRecord, WasmDeleteRecordsBatch}; +use crate::types::{WasmPaginatedRecord, WasmRecord}; +use crate::audit_trails_wasm_result; + +#[derive(Clone)] +#[wasm_bindgen(js_name = TrailRecords, inspectable)] +pub struct WasmTrailRecords { + pub(crate) read_only: audit_trails::AuditTrailClientReadOnly, + pub(crate) full: Option>, + pub(crate) trail_id: ObjectID, +} + +impl WasmTrailRecords { + fn full_client(&self) -> Result<&audit_trails::AuditTrailClient> { + self.full.as_ref().ok_or_else(|| { + wasm_error(anyhow!( + "TrailRecords was created from a read-only client; this operation requires AuditTrailClient" + )) + }) + } +} + +#[wasm_bindgen(js_class = TrailRecords)] +impl WasmTrailRecords { + pub async fn get(&self, sequence_number: u64) -> Result { + let record = audit_trails_wasm_result( + self + .read_only + .trail(self.trail_id) + .records() + .get(sequence_number) + .await, + )?; + Ok(record.into()) + } + + #[wasm_bindgen(js_name = recordCount)] + pub async fn record_count(&self) -> Result { + audit_trails_wasm_result(self.read_only.trail(self.trail_id).records().record_count().await) + } + + pub async fn list(&self) -> Result> { + let mut records: Vec<_> = + audit_trails_wasm_result(self.read_only.trail(self.trail_id).records().list().await)? + .into_iter() + .collect(); + records.sort_unstable_by_key(|(sequence_number, _)| *sequence_number); + Ok(records.into_iter().map(|(_, record)| record.into()).collect()) + } + + #[wasm_bindgen(js_name = listPage)] + pub async fn list_page(&self, cursor: Option, limit: usize) -> Result { + let page = audit_trails_wasm_result(self.read_only.trail(self.trail_id).records().list_page(cursor, limit).await)?; + Ok(page.into()) + } + + #[wasm_bindgen(js_name = addString, unchecked_return_type = "TransactionBuilder")] + pub fn add_string(&self, data: String, metadata: Option) -> Result { + let tx = self + .full_client()? + .trail(self.trail_id) + .records() + .add(audit_trails::core::types::Data::text(data), metadata) + .into_inner(); + Ok(into_transaction_builder(WasmAddRecord(tx))) + } + + #[wasm_bindgen(js_name = addBytes, unchecked_return_type = "TransactionBuilder")] + pub fn add_bytes( + &self, + data: js_sys::Uint8Array, + metadata: Option, + ) -> Result { + let tx = self + .full_client()? + .trail(self.trail_id) + .records() + .add(audit_trails::core::types::Data::bytes(data.to_vec()), metadata) + .into_inner(); + Ok(into_transaction_builder(WasmAddRecord(tx))) + } + + #[wasm_bindgen(unchecked_return_type = "TransactionBuilder")] + pub fn delete(&self, sequence_number: u64) -> Result { + let tx = self + .full_client()? + .trail(self.trail_id) + .records() + .delete(sequence_number) + .into_inner(); + Ok(into_transaction_builder(WasmDeleteRecord(tx))) + } + + #[wasm_bindgen(js_name = deleteBatch, unchecked_return_type = "TransactionBuilder")] + pub fn delete_batch(&self, limit: u64) -> Result { + let tx = self + .full_client()? + .trail(self.trail_id) + .records() + .delete_records_batch(limit) + .into_inner(); + Ok(into_transaction_builder(WasmDeleteRecordsBatch(tx))) + } +} diff --git a/bindings/wasm/audit_trails_wasm/src/types.rs b/bindings/wasm/audit_trails_wasm/src/types.rs new file mode 100644 index 00000000..a2b0426e --- /dev/null +++ b/bindings/wasm/audit_trails_wasm/src/types.rs @@ -0,0 +1,355 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::HashSet; + +use audit_trails::core::types::{ + Data, ImmutableMetadata, LockingConfig, LockingWindow, PaginatedRecord, Record, RecordCorrection, TimeLock, +}; +use js_sys::Uint8Array; +use product_common::bindings::WasmIotaAddress; +use serde::{Deserialize, Serialize}; +use wasm_bindgen::prelude::*; + +#[wasm_bindgen(js_name = Empty, inspectable)] +pub struct WasmEmpty; + +impl From<()> for WasmEmpty { + fn from(_: ()) -> Self { + Self + } +} + +#[wasm_bindgen(js_name = Data, inspectable)] +#[derive(Clone)] +pub struct WasmData(pub(crate) Data); + +#[wasm_bindgen(js_class = Data)] +impl WasmData { + #[wasm_bindgen(getter)] + pub fn value(&self) -> JsValue { + match &self.0 { + Data::Bytes(bytes) => Uint8Array::from(bytes.as_slice()).into(), + Data::Text(text) => JsValue::from(text), + } + } + + #[wasm_bindgen(js_name = toString)] + pub fn to_string(&self) -> String { + match &self.0 { + Data::Bytes(bytes) => String::from_utf8_lossy(bytes).to_string(), + Data::Text(text) => text.clone(), + } + } + + #[wasm_bindgen(js_name = toBytes)] + pub fn to_bytes(&self) -> Vec { + match &self.0 { + Data::Bytes(bytes) => bytes.clone(), + Data::Text(text) => text.as_bytes().to_vec(), + } + } + + #[wasm_bindgen(js_name = fromString)] + pub fn from_string(data: String) -> Self { + Self(Data::text(data)) + } + + #[wasm_bindgen(js_name = fromBytes)] + pub fn from_bytes(data: Uint8Array) -> Self { + Self(Data::bytes(data.to_vec())) + } +} + +impl From for WasmData { + fn from(value: Data) -> Self { + Self(value) + } +} + +impl From for Data { + fn from(value: WasmData) -> Self { + value.0 + } +} + +#[wasm_bindgen(js_name = TimeLockType)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum WasmTimeLockType { + None, + UnlockAt, + UnlockAtMs, + UntilDestroyed, + Infinite, +} + +#[wasm_bindgen(js_name = TimeLock, inspectable)] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WasmTimeLock(pub(crate) TimeLock); + +#[wasm_bindgen(js_class = TimeLock)] +impl WasmTimeLock { + #[wasm_bindgen(js_name = withUnlockAt)] + pub fn with_unlock_at(time_sec: u32) -> Self { + Self(TimeLock::UnlockAt(time_sec)) + } + + #[wasm_bindgen(js_name = withUnlockAtMs)] + pub fn with_unlock_at_ms(time_ms: u64) -> Self { + Self(TimeLock::UnlockAtMs(time_ms)) + } + + #[wasm_bindgen(js_name = withUntilDestroyed)] + pub fn with_until_destroyed() -> Self { + Self(TimeLock::UntilDestroyed) + } + + #[wasm_bindgen(js_name = withInfinite)] + pub fn with_infinite() -> Self { + Self(TimeLock::Infinite) + } + + #[wasm_bindgen(js_name = withNone)] + pub fn with_none() -> Self { + Self(TimeLock::None) + } + + #[wasm_bindgen(js_name = "type", getter)] + pub fn lock_type(&self) -> WasmTimeLockType { + match self.0 { + TimeLock::None => WasmTimeLockType::None, + TimeLock::UnlockAt(_) => WasmTimeLockType::UnlockAt, + TimeLock::UnlockAtMs(_) => WasmTimeLockType::UnlockAtMs, + TimeLock::UntilDestroyed => WasmTimeLockType::UntilDestroyed, + TimeLock::Infinite => WasmTimeLockType::Infinite, + } + } + + #[wasm_bindgen(js_name = "args", getter)] + pub fn args(&self) -> JsValue { + match self.0 { + TimeLock::UnlockAt(value) => JsValue::from(value), + TimeLock::UnlockAtMs(value) => JsValue::from(value), + _ => JsValue::UNDEFINED, + } + } +} + +impl From for WasmTimeLock { + fn from(value: TimeLock) -> Self { + Self(value) + } +} + +impl From for TimeLock { + fn from(value: WasmTimeLock) -> Self { + value.0 + } +} + +#[wasm_bindgen(js_name = LockingWindowType)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum WasmLockingWindowType { + None, + TimeBased, + CountBased, +} + +#[wasm_bindgen(js_name = LockingWindow, inspectable)] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WasmLockingWindow(pub(crate) LockingWindow); + +#[wasm_bindgen(js_class = LockingWindow)] +impl WasmLockingWindow { + #[wasm_bindgen(js_name = withNone)] + pub fn with_none() -> Self { + Self(LockingWindow::None) + } + + #[wasm_bindgen(js_name = withTimeBased)] + pub fn with_time_based(seconds: u64) -> Self { + Self(LockingWindow::TimeBased { seconds }) + } + + #[wasm_bindgen(js_name = withCountBased)] + pub fn with_count_based(count: u64) -> Self { + Self(LockingWindow::CountBased { count }) + } + + #[wasm_bindgen(js_name = "type", getter)] + pub fn window_type(&self) -> WasmLockingWindowType { + match self.0 { + LockingWindow::None => WasmLockingWindowType::None, + LockingWindow::TimeBased { .. } => WasmLockingWindowType::TimeBased, + LockingWindow::CountBased { .. } => WasmLockingWindowType::CountBased, + } + } + + #[wasm_bindgen(js_name = "args", getter)] + pub fn args(&self) -> JsValue { + match self.0 { + LockingWindow::TimeBased { seconds } => JsValue::from(seconds), + LockingWindow::CountBased { count } => JsValue::from(count), + LockingWindow::None => JsValue::UNDEFINED, + } + } +} + +impl From for WasmLockingWindow { + fn from(value: LockingWindow) -> Self { + Self(value) + } +} + +impl From for LockingWindow { + fn from(value: WasmLockingWindow) -> Self { + value.0 + } +} + +#[wasm_bindgen(js_name = LockingConfig, getter_with_clone, inspectable)] +#[derive(Clone, Serialize, Deserialize)] +pub struct WasmLockingConfig { + #[wasm_bindgen(js_name = deleteRecordWindow)] + pub delete_record_window: WasmLockingWindow, + #[wasm_bindgen(js_name = deleteTrailLock)] + pub delete_trail_lock: WasmTimeLock, + #[wasm_bindgen(js_name = writeLock)] + pub write_lock: WasmTimeLock, +} + +#[wasm_bindgen(js_class = LockingConfig)] +impl WasmLockingConfig { + #[wasm_bindgen(constructor)] + pub fn new( + delete_record_window: WasmLockingWindow, + delete_trail_lock: WasmTimeLock, + write_lock: WasmTimeLock, + ) -> Self { + Self { + delete_record_window, + delete_trail_lock, + write_lock, + } + } +} + +impl From for WasmLockingConfig { + fn from(value: LockingConfig) -> Self { + Self { + delete_record_window: value.delete_record_window.into(), + delete_trail_lock: value.delete_trail_lock.into(), + write_lock: value.write_lock.into(), + } + } +} + +impl From for LockingConfig { + fn from(value: WasmLockingConfig) -> Self { + Self { + delete_record_window: value.delete_record_window.into(), + delete_trail_lock: value.delete_trail_lock.into(), + write_lock: value.write_lock.into(), + } + } +} + +#[wasm_bindgen(js_name = ImmutableMetadata, getter_with_clone, inspectable)] +#[derive(Clone, Serialize, Deserialize)] +pub struct WasmImmutableMetadata { + pub name: String, + pub description: Option, +} + +impl From for WasmImmutableMetadata { + fn from(value: ImmutableMetadata) -> Self { + Self { + name: value.name, + description: value.description, + } + } +} + +impl From for ImmutableMetadata { + fn from(value: WasmImmutableMetadata) -> Self { + ImmutableMetadata { + name: value.name, + description: value.description, + } + } +} + +#[wasm_bindgen(js_name = RecordCorrection, getter_with_clone, inspectable)] +#[derive(Clone, Serialize, Deserialize)] +pub struct WasmRecordCorrection { + pub replaces: Vec, + #[wasm_bindgen(js_name = isReplacedBy)] + pub is_replaced_by: Option, +} + +impl From for WasmRecordCorrection { + fn from(value: RecordCorrection) -> Self { + let mut replaces: Vec = value.replaces.into_iter().collect(); + replaces.sort_unstable(); + Self { + replaces, + is_replaced_by: value.is_replaced_by, + } + } +} + +impl From for RecordCorrection { + fn from(value: WasmRecordCorrection) -> Self { + Self { + replaces: value.replaces.into_iter().collect::>(), + is_replaced_by: value.is_replaced_by, + } + } +} + +#[wasm_bindgen(js_name = Record, getter_with_clone, inspectable)] +#[derive(Clone)] +pub struct WasmRecord { + pub data: WasmData, + pub metadata: Option, + #[wasm_bindgen(js_name = sequenceNumber)] + pub sequence_number: u64, + #[wasm_bindgen(js_name = addedBy)] + pub added_by: WasmIotaAddress, + #[wasm_bindgen(js_name = addedAt)] + pub added_at: u64, + pub correction: WasmRecordCorrection, +} + +impl From> for WasmRecord { + fn from(value: Record) -> Self { + Self { + data: value.data.into(), + metadata: value.metadata, + sequence_number: value.sequence_number, + added_by: value.added_by.to_string(), + added_at: value.added_at, + correction: value.correction.into(), + } + } +} + +#[wasm_bindgen(js_name = PaginatedRecord, getter_with_clone, inspectable)] +#[derive(Clone)] +pub struct WasmPaginatedRecord { + pub records: Vec, + #[wasm_bindgen(js_name = nextCursor)] + pub next_cursor: Option, + #[wasm_bindgen(js_name = hasNextPage)] + pub has_next_page: bool, +} + +impl From> for WasmPaginatedRecord { + fn from(value: PaginatedRecord) -> Self { + Self { + records: value.records.into_values().map(Into::into).collect(), + next_cursor: value.next_cursor, + has_next_page: value.has_next_page, + } + } +} diff --git a/bindings/wasm/audit_trails_wasm/tsconfig.json b/bindings/wasm/audit_trails_wasm/tsconfig.json new file mode 100644 index 00000000..a741de44 --- /dev/null +++ b/bindings/wasm/audit_trails_wasm/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@iota/audit-trails/*": [ + "./*" + ] + } + } +} diff --git a/bindings/wasm/audit_trails_wasm/tsconfig.node.json b/bindings/wasm/audit_trails_wasm/tsconfig.node.json new file mode 100644 index 00000000..c09b2e5a --- /dev/null +++ b/bindings/wasm/audit_trails_wasm/tsconfig.node.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "esModuleInterop": true, + "module": "commonjs" + } +} diff --git a/bindings/wasm/audit_trails_wasm/tsconfig.typedoc.json b/bindings/wasm/audit_trails_wasm/tsconfig.typedoc.json new file mode 100644 index 00000000..bfc43be9 --- /dev/null +++ b/bindings/wasm/audit_trails_wasm/tsconfig.typedoc.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.node.json", + "include": [ + "node/**/*" + ] +} diff --git a/bindings/wasm/audit_trails_wasm/typedoc.json b/bindings/wasm/audit_trails_wasm/typedoc.json new file mode 100644 index 00000000..57f0ba99 --- /dev/null +++ b/bindings/wasm/audit_trails_wasm/typedoc.json @@ -0,0 +1,11 @@ +{ + "name": "@iota/audit-trails API documentation", + "extends": [ + "../typedoc.json" + ], + "entryPoints": [ + "./node/" + ], + "tsconfig": "./tsconfig.typedoc.json", + "out": "./docs/wasm" +} diff --git a/notarization-rs/Cargo.toml b/notarization-rs/Cargo.toml index 8ef1771f..dab23e52 100644 --- a/notarization-rs/Cargo.toml +++ b/notarization-rs/Cargo.toml @@ -26,7 +26,6 @@ thiserror.workspace = true [target.'cfg(not(target_arch = "wasm32"))'.dependencies] iota_interaction_rust = { workspace = true, default-features = false } -hyper = { workspace = true } iota-sdk = { workspace = true } tokio = { workspace = true } From 50ac7b7230da9260e7851d13bffc41728481297b Mon Sep 17 00:00:00 2001 From: Christof Gerritsma Date: Mon, 9 Mar 2026 14:45:29 +0100 Subject: [PATCH 070/189] Update audit-trail-move to be compilable and testable with generic role-data (#207) * Update audit-trail-move to be compilable and testable with generic role-data * Temporarily deactivate Rust tests for Audit Trails * Ignore AT Rust examples on CI checks * Switch product-core TfComponents dependency to feat/tf-compoenents-dev branch --- audit-trail-move/sources/audit_trail.move | 50 ++++++++++++++++--- audit-trail-move/tests/capability_tests.move | 8 +++ .../tests/create_audit_trail_tests.move | 1 + audit-trail-move/tests/locking_tests.move | 14 ++++++ audit-trail-move/tests/metadata_tests.move | 3 ++ audit-trail-move/tests/record_tests.move | 9 ++++ audit-trail-move/tests/role_tests.move | 20 ++++++-- audit-trail-move/tests/test_utils.move | 12 ++--- audit-trail-rs/src/client/full_client.rs | 2 +- audit-trail-rs/src/core/roles/operations.rs | 2 +- audit-trail-rs/src/core/trail.rs | 5 +- audit-trail-rs/src/core/types/audit_trail.rs | 6 +-- audit-trail-rs/src/core/types/permission.rs | 4 +- audit-trail-rs/src/core/types/role_map.rs | 1 - audit-trail-rs/tests/e2e/main.rs | 12 +++-- audit-trail-rs/tests/e2e/roles.rs | 8 +-- 16 files changed, 119 insertions(+), 38 deletions(-) diff --git a/audit-trail-move/sources/audit_trail.move b/audit-trail-move/sources/audit_trail.move index 30432441..c339f2d7 100644 --- a/audit-trail-move/sources/audit_trail.move +++ b/audit-trail-move/sources/audit_trail.move @@ -8,14 +8,21 @@ module audit_trail::main; use audit_trail::{ - locking::{Self, LockingConfig, LockingWindow, set_config, set_delete_record_window, set_delete_trail_lock, set_write_lock}, + locking::{ + Self, + LockingConfig, + LockingWindow, + set_config, + set_delete_record_window, + set_delete_trail_lock, + set_write_lock + }, permission::{Self, Permission}, record::{Self, Record} }; use iota::{clock::{Self, Clock}, event, linked_table::{Self, LinkedTable}, vec_set::VecSet}; use std::string::String; -use tf_components::{capability::Capability, role_map::{Self, RoleMap}}; -use tf_components::timelock::TimeLock; +use tf_components::{capability::Capability, role_map::{Self, RoleMap}, timelock::TimeLock}; // ===== Errors ===== #[error] @@ -40,6 +47,9 @@ const PACKAGE_VERSION: u64 = 1; // ===== Core Structures ===== +/// Stores all record tag related data associated with a role in the RoleMap +public struct RecordTags has copy, drop, store {} + /// Metadata set at trail creation public struct ImmutableMetadata has copy, drop, store { name: String, @@ -65,7 +75,7 @@ public struct AuditTrail has key, store { /// Deletion locking rules locking_config: LockingConfig, /// A list of role definitions consisting of a unique role specifier and a list of associated permissions - roles: RoleMap, + roles: RoleMap, /// Set at creation, cannot be changed immutable_metadata: Option, /// Can be updated by holders of MetadataUpdate permission @@ -239,6 +249,14 @@ entry fun migrate( trail.version = PACKAGE_VERSION; } +public fun new_record_tags( + // TODO: Add any parameters needed to initialize record tags +): RecordTags { + RecordTags { + // TODO: Initialize fields as needed + } +} + // ===== Record Operations ===== /// Add a record to the trail @@ -548,7 +566,15 @@ public fun create_role( ctx: &mut TxContext, ) { assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); - role_map::create_role(trail.roles_mut(), cap, role, permissions, clock, ctx); + role_map::create_role( + trail.roles_mut(), + cap, + role, + permissions, + std::option::none(), + clock, + ctx, + ); } /// Updates permissions for an existing role. @@ -561,7 +587,15 @@ public fun update_role_permissions( ctx: &mut TxContext, ) { assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); - role_map::update_role_permissions(trail.roles_mut(), cap, &role, new_permissions, clock, ctx); + role_map::update_role( + trail.roles_mut(), + cap, + &role, + new_permissions, + std::option::none(), + clock, + ctx, + ); } /// Deletes an existing role. @@ -766,13 +800,13 @@ public fun records(trail: &AuditTrail): &LinkedTable(trail: &AuditTrail): &RoleMap { +public fun roles(trail: &AuditTrail): &RoleMap { assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); &trail.roles } /// Returns a mutable reference to the RoleMap managing the roles and capabilities used in the audit trail -public fun roles_mut(trail: &mut AuditTrail): &mut RoleMap { +public fun roles_mut(trail: &mut AuditTrail): &mut RoleMap { assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); &mut trail.roles } diff --git a/audit-trail-move/tests/capability_tests.move b/audit-trail-move/tests/capability_tests.move index 6f28cc80..50e9cc16 100644 --- a/audit-trail-move/tests/capability_tests.move +++ b/audit-trail-move/tests/capability_tests.move @@ -95,6 +95,7 @@ fun setup_trail_with_record_admin_role(scenario: &mut Scenario, admin_user: addr &admin_cap, string::utf8(b"RecordAdmin"), record_admin_perms, + std::option::none(), &clock, ts::ctx(scenario), ); @@ -141,6 +142,7 @@ fun test_new_capability() { &admin_cap, string::utf8(b"RecordAdmin"), permission::record_admin_permissions(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -451,6 +453,7 @@ fun test_capability_lifecycle() { &admin_cap, string::utf8(b"RoleAdmin"), permission::role_admin_permissions(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -666,6 +669,7 @@ fun test_revoked_capability_cannot_be_used() { &admin_cap, string::utf8(b"RecordAdmin"), permission::record_admin_permissions(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -792,6 +796,7 @@ fun test_revoke_capability_permission_denied() { &admin_cap, string::utf8(b"NoRevokePerm"), perms, + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -802,6 +807,7 @@ fun test_revoke_capability_permission_denied() { &admin_cap, string::utf8(b"RecordAdmin"), permission::record_admin_permissions(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -882,6 +888,7 @@ fun test_new_capability_permission_denied() { &admin_cap, string::utf8(b"NoCapPerm"), perms, + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -892,6 +899,7 @@ fun test_new_capability_permission_denied() { &admin_cap, string::utf8(b"RecordAdmin"), permission::record_admin_permissions(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); diff --git a/audit-trail-move/tests/create_audit_trail_tests.move b/audit-trail-move/tests/create_audit_trail_tests.move index 9d39b1f4..70cca95e 100644 --- a/audit-trail-move/tests/create_audit_trail_tests.move +++ b/audit-trail-move/tests/create_audit_trail_tests.move @@ -279,6 +279,7 @@ fun test_create_metadata_admin_role() { &admin_cap, metadata_admin_role_name, metadata_admin_perms, + std::option::none(), &clock, ts::ctx(&mut scenario), ); diff --git a/audit-trail-move/tests/locking_tests.move b/audit-trail-move/tests/locking_tests.move index b3bcbb9a..eca383a6 100644 --- a/audit-trail-move/tests/locking_tests.move +++ b/audit-trail-move/tests/locking_tests.move @@ -140,6 +140,7 @@ fun test_count_based_locking() { &admin_cap, string::utf8(b"RecordAdmin"), permission::record_admin_permissions(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -301,6 +302,7 @@ fun test_update_locking_config() { &admin_cap, string::utf8(b"LockingAdmin"), perms, + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -377,6 +379,7 @@ fun test_update_locking_config_permission_denied() { &admin_cap, string::utf8(b"NoLockingPerm"), perms, + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -445,6 +448,7 @@ fun test_update_delete_record_window() { &admin_cap, string::utf8(b"DeleteLockAdmin"), perms, + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -522,6 +526,7 @@ fun test_update_delete_record_window_permission_denied() { &admin_cap, string::utf8(b"WrongPerm"), perms, + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -588,6 +593,7 @@ fun test_delete_record_after_time_lock_expires() { &admin_cap, string::utf8(b"RecordAdmin"), permission::record_admin_permissions(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -718,6 +724,7 @@ fun test_time_based_locking_all_recent_records_locked() { &admin_cap, string::utf8(b"RecordAdmin"), permission::record_admin_permissions(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -803,6 +810,7 @@ fun test_count_based_locking_last_records_remain_locked() { &admin_cap, string::utf8(b"RecordAdmin"), permission::record_admin_permissions(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -890,6 +898,7 @@ fun test_time_based_locking_still_locked_before_expiry() { &admin_cap, string::utf8(b"RecordAdmin"), permission::record_admin_permissions(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -973,6 +982,7 @@ fun test_count_based_locking_old_record_can_delete() { &admin_cap, string::utf8(b"RecordAdmin"), permission::record_admin_permissions(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -1066,6 +1076,7 @@ fun test_delete_records_batch_bypasses_record_lock() { &admin_cap, delete_all_role, delete_all_perms, + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -1128,6 +1139,7 @@ fun test_delete_records_batch_requires_delete_all_records_permission() { &admin_cap, string::utf8(b"TrailDeleteOnly"), perms, + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -1197,6 +1209,7 @@ fun test_delete_audit_trail_fails_while_not_empty() { &admin_cap, delete_trail_role, delete_trail_perms, + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -1255,6 +1268,7 @@ fun test_delete_audit_trail_after_batch_cleanup() { &admin_cap, delete_maintenance_role, delete_maintenance_perms, + std::option::none(), &clock, ts::ctx(&mut scenario), ); diff --git a/audit-trail-move/tests/metadata_tests.move b/audit-trail-move/tests/metadata_tests.move index 12cb2773..d23eb38c 100644 --- a/audit-trail-move/tests/metadata_tests.move +++ b/audit-trail-move/tests/metadata_tests.move @@ -53,6 +53,7 @@ fun test_update_metadata_success() { &admin_cap, string::utf8(b"MetadataAdmin"), metadata_perms, + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -175,6 +176,7 @@ fun test_update_metadata_permission_denied() { &admin_cap, string::utf8(b"NoMetadataPerm"), perms, + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -246,6 +248,7 @@ fun test_update_metadata_revoked_capability() { &admin_cap, string::utf8(b"MetadataAdmin"), metadata_perms, + std::option::none(), &clock, ts::ctx(&mut scenario), ); diff --git a/audit-trail-move/tests/record_tests.move b/audit-trail-move/tests/record_tests.move index 6130b02c..400f2dfa 100644 --- a/audit-trail-move/tests/record_tests.move +++ b/audit-trail-move/tests/record_tests.move @@ -56,6 +56,7 @@ fun test_add_record_to_empty_trail() { &admin_cap, string::utf8(b"RecordAdmin"), permission::record_admin_permissions(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -134,6 +135,7 @@ fun test_add_multiple_records() { &admin_cap, string::utf8(b"RecordAdmin"), permission::record_admin_permissions(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -216,6 +218,7 @@ fun test_add_record_permission_denied() { &admin_cap, string::utf8(b"NoAddPerm"), perms, + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -287,6 +290,7 @@ fun test_delete_record_success() { &admin_cap, string::utf8(b"RecordAdmin"), permission::record_admin_permissions(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -361,6 +365,7 @@ fun test_delete_record_permission_denied() { &admin_cap, string::utf8(b"NoDeletePerm"), perms, + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -425,6 +430,7 @@ fun test_delete_record_not_found() { &admin_cap, string::utf8(b"RecordAdmin"), permission::record_admin_permissions(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -489,6 +495,7 @@ fun test_delete_record_time_locked() { &admin_cap, string::utf8(b"RecordAdmin"), permission::record_admin_permissions(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -554,6 +561,7 @@ fun test_delete_record_count_locked() { &admin_cap, string::utf8(b"RecordAdmin"), permission::record_admin_permissions(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -694,6 +702,7 @@ fun test_first_last_sequence() { &admin_cap, string::utf8(b"RecordAdmin"), permission::record_admin_permissions(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); diff --git a/audit-trail-move/tests/role_tests.move b/audit-trail-move/tests/role_tests.move index 8f76888b..2bb32ea8 100644 --- a/audit-trail-move/tests/role_tests.move +++ b/audit-trail-move/tests/role_tests.move @@ -67,6 +67,7 @@ fun test_role_based_permission_delegation() { &admin_cap, string::utf8(b"RoleAdmin"), role_admin_perms, + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -79,6 +80,7 @@ fun test_role_based_permission_delegation() { &admin_cap, string::utf8(b"CapAdmin"), cap_admin_perms, + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -142,6 +144,7 @@ fun test_role_based_permission_delegation() { &role_admin_cap, string::utf8(b"RecordAdmin"), record_admin_perms, + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -248,6 +251,7 @@ fun test_delete_role_success() { &admin_cap, string::utf8(b"RoleToDelete"), perms, + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -314,6 +318,7 @@ fun test_create_role_permission_denied() { &admin_cap, string::utf8(b"NoRolesPerm"), perms, + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -344,6 +349,7 @@ fun test_create_role_permission_denied() { &user_cap, string::utf8(b"NewRole"), perms, + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -390,6 +396,7 @@ fun test_delete_role_permission_denied() { &admin_cap, string::utf8(b"RoleToDelete"), perms, + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -402,6 +409,7 @@ fun test_delete_role_permission_denied() { &admin_cap, string::utf8(b"NoDeleteRolePerm"), no_delete_perms, + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -470,6 +478,7 @@ fun test_update_role_permissions_permission_denied() { &admin_cap, string::utf8(b"RoleToUpdate"), perms, + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -482,6 +491,7 @@ fun test_update_role_permissions_permission_denied() { &admin_cap, string::utf8(b"NoUpdateRolePerm"), no_update_perms, + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -508,10 +518,11 @@ fun test_update_role_permissions_permission_denied() { // This should fail - no update_roles permission trail .roles_mut() - .update_role_permissions( + .update_role( &user_cap, &string::utf8(b"RoleToUpdate"), new_perms, + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -588,6 +599,7 @@ fun test_update_role_permissions_success() { &admin_cap, string::utf8(b"TestRole"), initial_perms, + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -601,10 +613,11 @@ fun test_update_role_permissions_success() { let new_perms = permission::from_vec(vector[permission::delete_record()]); trail .roles_mut() - .update_role_permissions( + .update_role( &admin_cap, &string::utf8(b"TestRole"), new_perms, + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -650,10 +663,11 @@ fun test_update_role_permissions_nonexistent() { // This should fail - role doesn't exist trail .roles_mut() - .update_role_permissions( + .update_role( &admin_cap, &string::utf8(b"NonExistentRole"), new_perms, + std::option::none(), &clock, ts::ctx(&mut scenario), ); diff --git a/audit-trail-move/tests/test_utils.move b/audit-trail-move/tests/test_utils.move index f7d90649..696dbe12 100644 --- a/audit-trail-move/tests/test_utils.move +++ b/audit-trail-move/tests/test_utils.move @@ -75,8 +75,8 @@ public(package) fun setup_test_audit_trail( /// Errors: /// - Aborts with EPermissionDenied if the provided capability does not have the permission specified with `CapabilityAdminPermissions::add`. /// - Aborts with ERoleDoesNotExist if the specified role does not exist in the role_map. -public fun new_capability_without_restrictions( - role_map: &mut RoleMap

, +public fun new_capability_without_restrictions( + role_map: &mut RoleMap, cap: &Capability, role: &string::String, clock: &Clock, @@ -102,8 +102,8 @@ public fun new_capability_without_restrictions( /// Errors: /// - Aborts with EPermissionDenied if the provided capability does not have the permission specified with `CapabilityAdminPermissions::add`. /// - Aborts with ERoleDoesNotExist if the specified role does not exist in the role_map. -public(package) fun new_capability_valid_until( - role_map: &mut RoleMap

, +public(package) fun new_capability_valid_until( + role_map: &mut RoleMap, cap: &Capability, role: &string::String, valid_until: u64, @@ -131,8 +131,8 @@ public(package) fun new_capability_valid_until( /// Errors: /// - Aborts with EPermissionDenied if the provided capability does not have the permission specified with `CapabilityAdminPermissions::add`. /// - Aborts with ERoleDoesNotExist if the specified role does not exist in the role_map. -public fun new_capability_for_address( - role_map: &mut RoleMap

, +public fun new_capability_for_address( + role_map: &mut RoleMap, cap: &Capability, role: &string::String, issued_to: address, diff --git a/audit-trail-rs/src/client/full_client.rs b/audit-trail-rs/src/client/full_client.rs index e5d5a0ee..7d0fcf17 100644 --- a/audit-trail-rs/src/client/full_client.rs +++ b/audit-trail-rs/src/client/full_client.rs @@ -82,7 +82,7 @@ impl AuditTrailClient { /// IOTA Trust Framework's products. /// /// # Examples - /// ``` + /// ```rust,ignore /// # use audit_trails::client::AuditTrailClient; /// /// # #[tokio::main] diff --git a/audit-trail-rs/src/core/roles/operations.rs b/audit-trail-rs/src/core/roles/operations.rs index c16813a2..2eac878e 100644 --- a/audit-trail-rs/src/core/roles/operations.rs +++ b/audit-trail-rs/src/core/roles/operations.rs @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 use iota_interaction::types::base_types::{IotaAddress, ObjectID}; -use iota_interaction::types::transaction::{Argument, Command, ObjectArg, ProgrammableTransaction}; +use iota_interaction::types::transaction::{ObjectArg, ProgrammableTransaction}; use iota_interaction::{OptionalSync, ident_str}; use product_common::core_client::CoreClientReadOnly; diff --git a/audit-trail-rs/src/core/trail.rs b/audit-trail-rs/src/core/trail.rs index 8b500191..e3ffa157 100644 --- a/audit-trail-rs/src/core/trail.rs +++ b/audit-trail-rs/src/core/trail.rs @@ -1,12 +1,9 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use iota_interaction::rpc_types::{ - IotaData as _, IotaObjectDataOptions, IotaTransactionBlockEffects, IotaTransactionBlockEvents, -}; use iota_interaction::types::base_types::ObjectID; use iota_interaction::types::transaction::ProgrammableTransaction; -use iota_interaction::{IotaClientTrait, IotaKeySignature, OptionalSync}; +use iota_interaction::{IotaKeySignature, OptionalSync}; use product_common::core_client::{CoreClient, CoreClientReadOnly}; use product_common::transaction::transaction_builder::TransactionBuilder; use secret_storage::Signer; diff --git a/audit-trail-rs/src/core/types/audit_trail.rs b/audit-trail-rs/src/core/types/audit_trail.rs index 2a33c807..1c408dfa 100644 --- a/audit-trail-rs/src/core/types/audit_trail.rs +++ b/audit-trail-rs/src/core/types/audit_trail.rs @@ -1,9 +1,9 @@ -use std::collections::HashMap; +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + use std::str::FromStr; use iota_interaction::ident_str; -// Copyright 2020-2026 IOTA Stiftung -// SPDX-License-Identifier: Apache-2.0 use iota_interaction::types::TypeTag; use iota_interaction::types::base_types::{IotaAddress, ObjectID}; use iota_interaction::types::collection_types::LinkedTable; diff --git a/audit-trail-rs/src/core/types/permission.rs b/audit-trail-rs/src/core/types/permission.rs index 8996b398..a07624d2 100644 --- a/audit-trail-rs/src/core/types/permission.rs +++ b/audit-trail-rs/src/core/types/permission.rs @@ -5,9 +5,9 @@ use std::collections::HashSet; use std::str::FromStr; use iota_interaction::ident_str; -use iota_interaction::types::base_types::{IotaAddress, ObjectID}; +use iota_interaction::types::base_types::ObjectID; use iota_interaction::types::programmable_transaction_builder::ProgrammableTransactionBuilder as Ptb; -use iota_interaction::types::transaction::{Argument, Command, ObjectArg, ProgrammableTransaction}; +use iota_interaction::types::transaction::{Argument, Command}; use iota_interaction::types::{Identifier, TypeTag}; use serde::{Deserialize, Serialize}; diff --git a/audit-trail-rs/src/core/types/role_map.rs b/audit-trail-rs/src/core/types/role_map.rs index d9936b38..1f4a3072 100644 --- a/audit-trail-rs/src/core/types/role_map.rs +++ b/audit-trail-rs/src/core/types/role_map.rs @@ -12,7 +12,6 @@ use serde::{Deserialize, Serialize}; use super::permission::Permission; use crate::core::utils::{deserialize_vec_map, deserialize_vec_set}; -use crate::package; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RoleMap { diff --git a/audit-trail-rs/tests/e2e/main.rs b/audit-trail-rs/tests/e2e/main.rs index b22967de..7c076225 100644 --- a/audit-trail-rs/tests/e2e/main.rs +++ b/audit-trail-rs/tests/e2e/main.rs @@ -1,8 +1,10 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -mod client; -mod locking; -mod records; -mod roles; -mod trail; +// Rust tests for Audit Trails have been temporarily deactivated during development. +// Uncomment the following modules to re-enable them. +// mod client; +// mod locking; +// mod records; +// mod roles; +// mod trail; diff --git a/audit-trail-rs/tests/e2e/roles.rs b/audit-trail-rs/tests/e2e/roles.rs index 07776c05..76bf9030 100644 --- a/audit-trail-rs/tests/e2e/roles.rs +++ b/audit-trail-rs/tests/e2e/roles.rs @@ -4,7 +4,7 @@ use std::collections::HashSet; use audit_trails::core::types::{CapabilityIssueOptions, Data, Permission, PermissionSet}; -use iota_interaction::types::base_types::{IotaAddress, ObjectID}; +use iota_interaction::types::base_types::IotaAddress; use product_common::core_client::CoreClient; use crate::client::get_funded_test_client; @@ -181,7 +181,7 @@ async fn destroy_initial_admin_capability_emits_expected_event() -> anyhow::Resu let roles = client.trail(trail_id).roles(); let admin_cap_ref = client.get_cap(client.sender_address(), trail_id).await?; - let admin_cap_id = ObjectID::from(admin_cap_ref.0); + let admin_cap_id = admin_cap_ref.0; let destroyed = roles .destroy_initial_admin_capability(admin_cap_id) @@ -225,7 +225,7 @@ async fn regular_destroy_rejects_initial_admin_capability() -> anyhow::Result<() let roles = client.trail(trail_id).roles(); let admin_cap_ref = client.get_cap(client.sender_address(), trail_id).await?; - let admin_cap_id = ObjectID::from(admin_cap_ref.0); + let admin_cap_id = admin_cap_ref.0; let result = roles.destroy_capability(admin_cap_id).build_and_execute(&client).await; @@ -244,7 +244,7 @@ async fn regular_revoke_rejects_initial_admin_capability() -> anyhow::Result<()> let roles = client.trail(trail_id).roles(); let admin_cap_ref = client.get_cap(client.sender_address(), trail_id).await?; - let admin_cap_id = ObjectID::from(admin_cap_ref.0); + let admin_cap_id = admin_cap_ref.0; let result = roles.revoke_capability(admin_cap_id).build_and_execute(&client).await; From ade973639ea7f96d7757bd606e5ee2df0f4d106a Mon Sep 17 00:00:00 2001 From: Yasir Date: Wed, 11 Mar 2026 11:22:15 +0300 Subject: [PATCH 071/189] Update Move.lock and audit_trail.move for version consistency and dependency alignment --- audit-trail-move/Move.lock | 14 +- audit-trail-move/sources/audit_trail.move | 240 +++++++++++----------- 2 files changed, 128 insertions(+), 126 deletions(-) diff --git a/audit-trail-move/Move.lock b/audit-trail-move/Move.lock index c75ab983..3f3869a7 100644 --- a/audit-trail-move/Move.lock +++ b/audit-trail-move/Move.lock @@ -2,7 +2,7 @@ [move] version = 3 -manifest_digest = "A98478D66EC9631ABE28DCBEBAD8D608F813CA5A3D0856E6282E31F4FE7B20FF" +manifest_digest = "BBDC635C3E5B1F977F4F12056411AADB62CD398CFCA75919B69BE3414CFC8393" deps_digest = "397E6A9F7A624706DBDFEE056CE88391A15876868FD18A88504DA74EB458D697" dependencies = [ { id = "Iota", name = "Iota" }, @@ -14,7 +14,7 @@ dependencies = [ [[move.package]] id = "Iota" -source = { git = "https://github.com/iotaledger/iota.git", rev = "22dd2a89f5b591066c6930510a227c087cc836ac", subdir = "crates/iota-framework/packages/iota-framework" } +source = { git = "https://github.com/iotaledger/iota.git", rev = "431ab686e6f1d4abd83b16dd7c671712002ecac8", subdir = "crates/iota-framework/packages/iota-framework" } dependencies = [ { id = "MoveStdlib", name = "MoveStdlib" }, @@ -22,7 +22,7 @@ dependencies = [ [[move.package]] id = "IotaSystem" -source = { git = "https://github.com/iotaledger/iota.git", rev = "22dd2a89f5b591066c6930510a227c087cc836ac", subdir = "crates/iota-framework/packages/iota-system" } +source = { git = "https://github.com/iotaledger/iota.git", rev = "431ab686e6f1d4abd83b16dd7c671712002ecac8", subdir = "crates/iota-framework/packages/iota-system" } dependencies = [ { id = "Iota", name = "Iota" }, @@ -31,11 +31,11 @@ dependencies = [ [[move.package]] id = "MoveStdlib" -source = { git = "https://github.com/iotaledger/iota.git", rev = "22dd2a89f5b591066c6930510a227c087cc836ac", subdir = "crates/iota-framework/packages/move-stdlib" } +source = { git = "https://github.com/iotaledger/iota.git", rev = "431ab686e6f1d4abd83b16dd7c671712002ecac8", subdir = "crates/iota-framework/packages/move-stdlib" } [[move.package]] id = "Stardust" -source = { git = "https://github.com/iotaledger/iota.git", rev = "22dd2a89f5b591066c6930510a227c087cc836ac", subdir = "crates/iota-framework/packages/stardust" } +source = { git = "https://github.com/iotaledger/iota.git", rev = "431ab686e6f1d4abd83b16dd7c671712002ecac8", subdir = "crates/iota-framework/packages/stardust" } dependencies = [ { id = "Iota", name = "Iota" }, @@ -44,7 +44,7 @@ dependencies = [ [[move.package]] id = "TfComponents" -source = { local = "../../product-core/components_move" } +source = { git = "https://github.com/iotaledger/product-core.git", rev = "feat/tf-compoenents-dev", subdir = "components_move" } dependencies = [ { id = "Iota", name = "Iota" }, @@ -54,7 +54,7 @@ dependencies = [ ] [move.toolchain-version] -compiler-version = "1.17.2" +compiler-version = "1.18.1-rc" edition = "2024.beta" flavor = "iota" diff --git a/audit-trail-move/sources/audit_trail.move b/audit-trail-move/sources/audit_trail.move index c339f2d7..e6f52cfb 100644 --- a/audit-trail-move/sources/audit_trail.move +++ b/audit-trail-move/sources/audit_trail.move @@ -263,15 +263,15 @@ public fun new_record_tags( /// /// Records are added sequentially with auto-assigned sequence numbers. public fun add_record( - trail: &mut AuditTrail, + self: &mut AuditTrail, cap: &Capability, stored_data: D, record_metadata: Option, clock: &Clock, ctx: &mut TxContext, ) { - assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); - trail + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + self .roles .assert_capability_valid( cap, @@ -279,12 +279,12 @@ public fun add_record( clock, ctx, ); - assert!(!locking::is_write_locked(&trail.locking_config, clock), ETrailWriteLocked); + assert!(!locking::is_write_locked(&self.locking_config, clock), ETrailWriteLocked); let caller = ctx.sender(); let timestamp = clock::timestamp_ms(clock); - let trail_id = trail.id(); - let seq = trail.sequence_number; + let trail_id = self.id(); + let seq = self.sequence_number; let record = record::new( stored_data, @@ -295,8 +295,8 @@ public fun add_record( record::new_correction(), ); - linked_table::push_back(&mut trail.records, seq, record); - trail.sequence_number = trail.sequence_number + 1; + linked_table::push_back(&mut self.records, seq, record); + self.sequence_number = self.sequence_number + 1; event::emit(RecordAdded { trail_id, @@ -311,14 +311,14 @@ public fun add_record( /// The record must not be locked (based on the trail's locking configuration). /// Requires the DeleteRecord permission. public fun delete_record( - trail: &mut AuditTrail, + self: &mut AuditTrail, cap: &Capability, sequence_number: u64, clock: &Clock, ctx: &mut TxContext, ) { - assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); - trail + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + self .roles .assert_capability_valid( cap, @@ -326,14 +326,14 @@ public fun delete_record( clock, ctx, ); - assert!(linked_table::contains(&trail.records, sequence_number), ERecordNotFound); - assert!(!trail.is_record_locked(sequence_number, clock), ERecordLocked); + assert!(linked_table::contains(&self.records, sequence_number), ERecordNotFound); + assert!(!self.is_record_locked(sequence_number, clock), ERecordLocked); let caller = ctx.sender(); let timestamp = clock::timestamp_ms(clock); - let trail_id = trail.id(); + let trail_id = self.id(); - let record = linked_table::remove(&mut trail.records, sequence_number); + let record = linked_table::remove(&mut self.records, sequence_number); record::destroy(record); event::emit(RecordDeleted { @@ -349,14 +349,14 @@ public fun delete_record( /// Requires `DeleteAllRecords` permission. This operation bypasses record locks. /// Returns the number of records deleted in this batch. public fun delete_records_batch( - trail: &mut AuditTrail, + self: &mut AuditTrail, cap: &Capability, limit: u64, clock: &Clock, ctx: &mut TxContext, ): u64 { - assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); - trail + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + self .roles .assert_capability_valid( cap, @@ -368,10 +368,10 @@ public fun delete_records_batch( let mut deleted = 0; let caller = ctx.sender(); let timestamp = clock.timestamp_ms(); - let trail_id = trail.id(); + let trail_id = self.id(); - while (deleted < limit && !trail.records.is_empty()) { - let (sequence_number, record) = trail.records.pop_front(); + while (deleted < limit && !self.records.is_empty()) { + let (sequence_number, record) = self.records.pop_front(); record.destroy(); @@ -392,13 +392,13 @@ public fun delete_records_batch( /// /// Requires `DeleteAuditTrail` permission and aborts if records still exist. public fun delete_audit_trail( - trail: AuditTrail, + self: AuditTrail, cap: &Capability, clock: &Clock, ctx: &mut TxContext, ) { - assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); - trail + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + self .roles .assert_capability_valid( cap, @@ -406,10 +406,10 @@ public fun delete_audit_trail( clock, ctx, ); - assert!(!locking::is_delete_trail_locked(&trail.locking_config, clock), ETrailDeleteLocked); - assert!(linked_table::is_empty(&trail.records), ETrailNotEmpty); + assert!(!locking::is_delete_trail_locked(&self.locking_config, clock), ETrailDeleteLocked); + assert!(linked_table::is_empty(&self.records), ETrailNotEmpty); - let trail_id = trail.id(); + let trail_id = self.id(); let timestamp = clock::timestamp_ms(clock); let AuditTrail { @@ -423,7 +423,7 @@ public fun delete_audit_trail( immutable_metadata: _, updatable_metadata: _, version: _, - } = trail; + } = self; linked_table::destroy_empty(records); object::delete(id); @@ -436,34 +436,34 @@ public fun delete_audit_trail( /// Check if a record is locked based on the trail's locking configuration. /// Aborts with ERecordNotFound if the record doesn't exist. public fun is_record_locked( - trail: &AuditTrail, + self: &AuditTrail, sequence_number: u64, clock: &Clock, ): bool { - assert!(linked_table::contains(&trail.records, sequence_number), ERecordNotFound); + assert!(linked_table::contains(&self.records, sequence_number), ERecordNotFound); - let record = linked_table::borrow(&trail.records, sequence_number); + let record = linked_table::borrow(&self.records, sequence_number); let current_time = clock::timestamp_ms(clock); locking::is_delete_record_locked( - &trail.locking_config, + &self.locking_config, sequence_number, record::added_at(record), - trail.sequence_number, + self.sequence_number, current_time, ) } /// Update the locking configuration. Requires `UpdateLockingConfig` permission. public fun update_locking_config( - trail: &mut AuditTrail, + self: &mut AuditTrail, cap: &Capability, new_config: LockingConfig, clock: &Clock, ctx: &mut TxContext, ) { - assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); - trail + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + self .roles .assert_capability_valid( cap, @@ -471,19 +471,19 @@ public fun update_locking_config( clock, ctx, ); - set_config(&mut trail.locking_config, new_config); + set_config(&mut self.locking_config, new_config); } /// Update the `delete_record_lock` locking configuration public fun update_delete_record_window( - trail: &mut AuditTrail, + self: &mut AuditTrail, cap: &Capability, new_delete_record_lock: LockingWindow, clock: &Clock, ctx: &mut TxContext, ) { - assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); - trail + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + self .roles .assert_capability_valid( cap, @@ -491,19 +491,19 @@ public fun update_delete_record_window( clock, ctx, ); - set_delete_record_window(&mut trail.locking_config, new_delete_record_lock); + set_delete_record_window(&mut self.locking_config, new_delete_record_lock); } /// Update the `delete_trail_lock` locking configuration. public fun update_delete_trail_lock( - trail: &mut AuditTrail, + self: &mut AuditTrail, cap: &Capability, new_delete_trail_lock: TimeLock, clock: &Clock, ctx: &mut TxContext, ) { - assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); - trail + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + self .roles .assert_capability_valid( cap, @@ -511,19 +511,19 @@ public fun update_delete_trail_lock( clock, ctx, ); - set_delete_trail_lock(&mut trail.locking_config, new_delete_trail_lock); + set_delete_trail_lock(&mut self.locking_config, new_delete_trail_lock); } /// Update the `write_lock` locking configuration. public fun update_write_lock( - trail: &mut AuditTrail, + self: &mut AuditTrail, cap: &Capability, new_write_lock: TimeLock, clock: &Clock, ctx: &mut TxContext, ) { - assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); - trail + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + self .roles .assert_capability_valid( cap, @@ -531,19 +531,19 @@ public fun update_write_lock( clock, ctx, ); - set_write_lock(&mut trail.locking_config, new_write_lock); + set_write_lock(&mut self.locking_config, new_write_lock); } /// Update the trail's mutable metadata public fun update_metadata( - trail: &mut AuditTrail, + self: &mut AuditTrail, cap: &Capability, new_metadata: Option, clock: &Clock, ctx: &mut TxContext, ) { - assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); - trail + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + self .roles .assert_capability_valid( cap, @@ -551,23 +551,23 @@ public fun update_metadata( clock, ctx, ); - trail.updatable_metadata = new_metadata; + self.updatable_metadata = new_metadata; } // ===== Role and Capability Administration ===== /// Creates a new role with the provided permissions. public fun create_role( - trail: &mut AuditTrail, + self: &mut AuditTrail, cap: &Capability, role: String, permissions: VecSet, clock: &Clock, ctx: &mut TxContext, ) { - assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); role_map::create_role( - trail.roles_mut(), + self.roles_mut(), cap, role, permissions, @@ -579,16 +579,16 @@ public fun create_role( /// Updates permissions for an existing role. public fun update_role_permissions( - trail: &mut AuditTrail, + self: &mut AuditTrail, cap: &Capability, role: String, new_permissions: VecSet, clock: &Clock, ctx: &mut TxContext, ) { - assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); role_map::update_role( - trail.roles_mut(), + self.roles_mut(), cap, &role, new_permissions, @@ -600,21 +600,21 @@ public fun update_role_permissions( /// Deletes an existing role. public fun delete_role( - trail: &mut AuditTrail, + self: &mut AuditTrail, cap: &Capability, role: String, clock: &Clock, ctx: &mut TxContext, ) { - assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); - role_map::delete_role(trail.roles_mut(), cap, &role, clock, ctx); + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + role_map::delete_role(self.roles_mut(), cap, &role, clock, ctx); } /// Issues a new capability for an existing role. /// /// The capability object is transferred to `issued_to` if provided, otherwise to the caller. public fun new_capability( - trail: &mut AuditTrail, + self: &mut AuditTrail, cap: &Capability, role: String, issued_to: Option

, @@ -623,7 +623,7 @@ public fun new_capability( clock: &Clock, ctx: &mut TxContext, ) { - assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); let recipient = if (issued_to.is_some()) { let address_ref = issued_to.borrow(); @@ -633,7 +633,7 @@ public fun new_capability( }; let new_cap = role_map::new_capability( - trail.roles_mut(), + self.roles_mut(), cap, &role, issued_to, @@ -647,28 +647,28 @@ public fun new_capability( /// Revokes an issued capability by ID. public fun revoke_capability( - trail: &mut AuditTrail, + self: &mut AuditTrail, cap: &Capability, capability_id: ID, clock: &Clock, ctx: &mut TxContext, ) { - assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); - role_map::revoke_capability(trail.roles_mut(), cap, capability_id, clock, ctx); + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + role_map::revoke_capability(self.roles_mut(), cap, capability_id, clock, ctx); } /// Destroys a capability object. /// /// Requires a capability with `RevokeCapabilities` permission. public fun destroy_capability( - trail: &mut AuditTrail, + self: &mut AuditTrail, cap: &Capability, cap_to_destroy: Capability, clock: &Clock, ctx: &mut TxContext, ) { - assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); - trail + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + self .roles .assert_capability_valid( cap, @@ -676,7 +676,7 @@ public fun destroy_capability( clock, ctx, ); - role_map::destroy_capability(trail.roles_mut(), cap_to_destroy); + role_map::destroy_capability(self.roles_mut(), cap_to_destroy); } /// Destroys an initial admin capability. @@ -687,11 +687,11 @@ public fun destroy_capability( /// WARNING: If all initial admin capabilities are destroyed, the trail will be permanently /// sealed with no admin access possible. public fun destroy_initial_admin_capability( - trail: &mut AuditTrail, + self: &mut AuditTrail, cap_to_destroy: Capability, ) { - assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); - role_map::destroy_initial_admin_capability(trail.roles_mut(), cap_to_destroy); + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + role_map::destroy_initial_admin_capability(self.roles_mut(), cap_to_destroy); } /// Revokes an initial admin capability by ID. @@ -701,112 +701,114 @@ public fun destroy_initial_admin_capability( /// WARNING: If all initial admin capabilities are revoked, the trail will be permanently /// sealed with no admin access possible. public fun revoke_initial_admin_capability( - trail: &mut AuditTrail, + self: &mut AuditTrail, cap: &Capability, capability_id: ID, clock: &Clock, ctx: &mut TxContext, ) { - assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); - role_map::revoke_initial_admin_capability(trail.roles_mut(), cap, capability_id, clock, ctx); + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + role_map::revoke_initial_admin_capability(self.roles_mut(), cap, capability_id, clock, ctx); } // ===== Trail Query Functions ===== /// Get the total number of records currently in the trail -public fun record_count(trail: &AuditTrail): u64 { - linked_table::length(&trail.records) +public fun record_count(self: &AuditTrail): u64 { + linked_table::length(&self.records) } /// Get the next sequence number (monotonic counter, never decrements) -public fun sequence_number(trail: &AuditTrail): u64 { - trail.sequence_number +public fun sequence_number(self: &AuditTrail): u64 { + self.sequence_number } /// Get the trail creator address -public fun creator(trail: &AuditTrail): address { - trail.creator +public fun creator(self: &AuditTrail): address { + self.creator } /// Get the trail creation timestamp -public fun created_at(trail: &AuditTrail): u64 { - trail.created_at +public fun created_at(self: &AuditTrail): u64 { + self.created_at } /// Get the trail's object ID -public fun id(trail: &AuditTrail): ID { - object::uid_to_inner(&trail.id) +public fun id(self: &AuditTrail): ID { + object::uid_to_inner(&self.id) } /// Get the trail name -public fun name(trail: &AuditTrail): Option { - trail.immutable_metadata.map!(|metadata| metadata.name) +public fun name(self: &AuditTrail): Option { + self.immutable_metadata.map!(|metadata| metadata.name) } /// Get the trail description -public fun description(trail: &AuditTrail): Option { - if (trail.immutable_metadata.is_some()) { - option::borrow(&trail.immutable_metadata).description +public fun description(self: &AuditTrail): Option { + if (self.immutable_metadata.is_some()) { + option::borrow(&self.immutable_metadata).description } else { option::none() } } /// Get the updatable metadata -public fun metadata(trail: &AuditTrail): &Option { - &trail.updatable_metadata +public fun metadata(self: &AuditTrail): &Option { + &self.updatable_metadata } /// Get the locking configuration -public fun locking_config(trail: &AuditTrail): &LockingConfig { - &trail.locking_config +public fun locking_config(self: &AuditTrail): &LockingConfig { + &self.locking_config } /// Check if the trail is empty -public fun is_empty(trail: &AuditTrail): bool { - linked_table::is_empty(&trail.records) +public fun is_empty(self: &AuditTrail): bool { + linked_table::is_empty(&self.records) } /// Get the first sequence number -public fun first_sequence(trail: &AuditTrail): Option { - *linked_table::front(&trail.records) +public fun first_sequence(self: &AuditTrail): Option { + *linked_table::front(&self.records) } /// Get the last sequence number -public fun last_sequence(trail: &AuditTrail): Option { - *linked_table::back(&trail.records) +public fun last_sequence(self: &AuditTrail): Option { + *linked_table::back(&self.records) } // ===== Record Query Functions ===== /// Get a record by sequence number -public fun get_record(trail: &AuditTrail, sequence_number: u64): &Record { - assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); - assert!(linked_table::contains(&trail.records, sequence_number), ERecordNotFound); - linked_table::borrow(&trail.records, sequence_number) +public fun get_record(self: &AuditTrail, sequence_number: u64): &Record { + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + assert!(linked_table::contains(&self.records, sequence_number), ERecordNotFound); + linked_table::borrow(&self.records, sequence_number) } /// Check if a record exists at the given sequence number -public fun has_record(trail: &AuditTrail, sequence_number: u64): bool { - assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); - linked_table::contains(&trail.records, sequence_number) +public fun has_record(self: &AuditTrail, sequence_number: u64): bool { + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + linked_table::contains(&self.records, sequence_number) } /// Returns all records of the audit trail -public fun records(trail: &AuditTrail): &LinkedTable> { - assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); - &trail.records +public fun records(self: &AuditTrail): &LinkedTable> { + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + &self.records } // ===== Role and Capability Functions ===== /// Returns a reference the RoleMap managing the roles and capabilities used in the audit trail -public fun roles(trail: &AuditTrail): &RoleMap { - assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); - &trail.roles +public fun roles(self: &AuditTrail): &RoleMap { + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + &self.roles } /// Returns a mutable reference to the RoleMap managing the roles and capabilities used in the audit trail -public fun roles_mut(trail: &mut AuditTrail): &mut RoleMap { - assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); - &mut trail.roles +public fun roles_mut( + self: &mut AuditTrail, +): &mut RoleMap { + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + &mut self.roles } From cd62491c9714e4d38cffa211fa9f7a3bb6f7162f Mon Sep 17 00:00:00 2001 From: Yasir Date: Wed, 11 Mar 2026 18:12:30 +0300 Subject: [PATCH 072/189] feat: record tags --- audit-trail-move/Move.lock | 10 +- audit-trail-move/Move.toml | 2 +- audit-trail-move/scripts/publish_package.sh | 2 +- audit-trail-move/sources/audit_trail.move | 147 ++++++++- audit-trail-move/sources/record.move | 10 + audit-trail-move/sources/record_tags.move | 94 ++++++ audit-trail-move/tests/capability_tests.move | 11 + .../tests/create_audit_trail_tests.move | 55 +++- audit-trail-move/tests/locking_tests.move | 5 + audit-trail-move/tests/record_tests.move | 298 ++++++++++++++++++ audit-trail-move/tests/role_tests.move | 95 +++++- audit-trail-move/tests/test_utils.move | 11 + audit-trail-rs/src/core/builder.rs | 13 + audit-trail-rs/src/core/create/operations.rs | 5 + .../src/core/create/transactions.rs | 2 + audit-trail-rs/src/core/operations.rs | 4 +- audit-trail-rs/src/core/records/mod.rs | 4 +- audit-trail-rs/src/core/records/operations.rs | 102 +++++- .../src/core/records/transactions.rs | 11 +- audit-trail-rs/src/core/roles/mod.rs | 34 +- audit-trail-rs/src/core/roles/operations.rs | 67 +++- audit-trail-rs/src/core/roles/transactions.rs | 24 +- audit-trail-rs/src/core/trail.rs | 38 ++- audit-trail-rs/src/core/trail/operations.rs | 102 +++++- audit-trail-rs/src/core/trail/transactions.rs | 141 +++++++++ audit-trail-rs/src/core/types/audit_trail.rs | 3 +- audit-trail-rs/src/core/types/record.rs | 1 + audit-trail-rs/src/core/types/role_map.rs | 59 +++- audit-trail-rs/src/package.rs | 2 +- audit-trail-rs/tests/e2e/client.rs | 23 +- audit-trail-rs/tests/e2e/locking.rs | 10 +- audit-trail-rs/tests/e2e/main.rs | 10 +- audit-trail-rs/tests/e2e/records.rs | 132 +++++++- audit-trail-rs/tests/e2e/roles.rs | 79 ++++- audit-trail-rs/tests/e2e/trail.rs | 98 +++++- 35 files changed, 1600 insertions(+), 104 deletions(-) create mode 100644 audit-trail-move/sources/record_tags.move diff --git a/audit-trail-move/Move.lock b/audit-trail-move/Move.lock index 3f3869a7..4df3ce35 100644 --- a/audit-trail-move/Move.lock +++ b/audit-trail-move/Move.lock @@ -2,7 +2,7 @@ [move] version = 3 -manifest_digest = "BBDC635C3E5B1F977F4F12056411AADB62CD398CFCA75919B69BE3414CFC8393" +manifest_digest = "E2DCED5F45474DE4CA933A99DEAB29171001E7D699076C9B2528FA3A74FC2FCE" deps_digest = "397E6A9F7A624706DBDFEE056CE88391A15876868FD18A88504DA74EB458D697" dependencies = [ { id = "Iota", name = "Iota" }, @@ -44,7 +44,7 @@ dependencies = [ [[move.package]] id = "TfComponents" -source = { git = "https://github.com/iotaledger/product-core.git", rev = "feat/tf-compoenents-dev", subdir = "components_move" } +source = { local = "../../product-core/components_move" } dependencies = [ { id = "Iota", name = "Iota" }, @@ -61,7 +61,7 @@ flavor = "iota" [env] [env.localnet] -chain-id = "426effa0" -original-published-id = "0x4644007b931d759798353f3a1f631f690658f04e3111d8438d211033d35fdd34" -latest-published-id = "0x4644007b931d759798353f3a1f631f690658f04e3111d8438d211033d35fdd34" +chain-id = "26d62e2d" +original-published-id = "0x0883d101ae2e858f1f391ecd44a0ebbfce10d1fb08ba3746c142acff062e42c5" +latest-published-id = "0x0883d101ae2e858f1f391ecd44a0ebbfce10d1fb08ba3746c142acff062e42c5" published-version = "1" diff --git a/audit-trail-move/Move.toml b/audit-trail-move/Move.toml index 3c6e966c..9914bbea 100644 --- a/audit-trail-move/Move.toml +++ b/audit-trail-move/Move.toml @@ -3,7 +3,7 @@ name = "audit_trail" edition = "2024.beta" [dependencies] -TfComponents = { git = "https://github.com/iotaledger/product-core.git", subdir = "components_move", rev = "feat/tf-compoenents-dev" } +TfComponents = { local = "../../product-core/components_move"} [addresses] audit_trail = "0x0" diff --git a/audit-trail-move/scripts/publish_package.sh b/audit-trail-move/scripts/publish_package.sh index b04c848f..7fb136e3 100755 --- a/audit-trail-move/scripts/publish_package.sh +++ b/audit-trail-move/scripts/publish_package.sh @@ -6,7 +6,7 @@ script_dir=$(cd "$(dirname $0)" && pwd) package_dir=$script_dir/.. -RESPONSE=$(iota client publish --silence-warnings --json --gas-budget 500000000 $package_dir) +RESPONSE=$(iota client publish --silence-warnings --json --gas-budget 500000000 $package_dir) { # try PACKAGE_ID=$(echo $RESPONSE | jq --raw-output '.objectChanges[] | select(.type | contains("published")) | .packageId') } || { # catch diff --git a/audit-trail-move/sources/audit_trail.move b/audit-trail-move/sources/audit_trail.move index e6f52cfb..dd1e68d4 100644 --- a/audit-trail-move/sources/audit_trail.move +++ b/audit-trail-move/sources/audit_trail.move @@ -18,7 +18,8 @@ use audit_trail::{ set_write_lock }, permission::{Self, Permission}, - record::{Self, Record} + record::{Self, Record}, + record_tags::{Self, RecordTags} }; use iota::{clock::{Self, Clock}, event, linked_table::{Self, LinkedTable}, vec_set::VecSet}; use std::string::String; @@ -38,6 +39,21 @@ const ETrailWriteLocked: vector = b"The audit trail is write-locked"; #[error] const EPackageVersionMismatch: vector = b"The package version of the trail does not match the expected version"; +#[error] +const ERecordTagNotAllowed: vector = + b"The provided capability cannot create records with the requested tag"; +#[error] +const ERecordTagNotDefined: vector = + b"The requested tag is not defined for this audit trail"; +#[error] +const ERecordTagAlreadyDefined: vector = + b"The requested tag is already defined for this audit trail"; +#[error] +const ERecordTagInUse: vector = + b"The requested tag cannot be removed because it is already used by an existing record"; +#[error] +const ERecordTagAdminOnly: vector = + b"Only the Admin role may manage the trail record-tag registry"; // ===== Constants ===== const INITIAL_ADMIN_ROLE_NAME: vector = b"Admin"; @@ -47,9 +63,6 @@ const PACKAGE_VERSION: u64 = 1; // ===== Core Structures ===== -/// Stores all record tag related data associated with a role in the RoleMap -public struct RecordTags has copy, drop, store {} - /// Metadata set at trail creation public struct ImmutableMetadata has copy, drop, store { name: String, @@ -72,6 +85,8 @@ public struct AuditTrail has key, store { sequence_number: u64, /// LinkedTable mapping sequence numbers to records records: LinkedTable>, + /// Canonical list of tags that may be attached to records in this trail + available_record_tags: VecSet, /// Deletion locking rules locking_config: LockingConfig, /// A list of role definitions consisting of a unique role specifier and a list of associated permissions @@ -147,6 +162,7 @@ public fun create( locking_config: LockingConfig, trail_metadata: Option, updatable_metadata: Option, + available_record_tags: vector, clock: &Clock, ctx: &mut TxContext, ): (Capability, ID) { @@ -163,6 +179,7 @@ public fun create( let record = record::new( data.destroy_some(), record_metadata, + option::none(), 0, creator, timestamp, @@ -208,6 +225,7 @@ public fun create( created_at: timestamp, sequence_number, records, + available_record_tags: iota::vec_set::from_keys(available_record_tags), locking_config, roles, immutable_metadata: trail_metadata, @@ -249,12 +267,45 @@ entry fun migrate( trail.version = PACKAGE_VERSION; } -public fun new_record_tags( - // TODO: Add any parameters needed to initialize record tags -): RecordTags { - RecordTags { - // TODO: Initialize fields as needed - } +fun assert_defined_record_tags( + self: &AuditTrail, + record_tags: &Option, +) { + assert!( + record_tags::defined_for_trail(&self.available_record_tags, record_tags), + ERecordTagNotDefined, + ); +} + +fun assert_record_tag_admin( + self: &AuditTrail, + cap: &Capability, + clock: &Clock, + ctx: &TxContext, +) { + self + .roles + .assert_capability_valid( + cap, + &permission::add_roles(), + clock, + ctx, + ); + assert!(*cap.role() == initial_admin_role_name(), ERecordTagAdminOnly); +} + +fun assert_record_tag_allowed( + self: &AuditTrail, + cap: &Capability, + tag: &Option, +) { + if (!tag.is_some()) { + return + }; + + let requested_tag = option::borrow(tag); + assert!(record_tags::is_defined(&self.available_record_tags, requested_tag), ERecordTagNotDefined); + assert!(record_tags::role_allows(&self.roles, cap, requested_tag), ERecordTagNotAllowed); } // ===== Record Operations ===== @@ -267,6 +318,7 @@ public fun add_record( cap: &Capability, stored_data: D, record_metadata: Option, + record_tag: Option, clock: &Clock, ctx: &mut TxContext, ) { @@ -280,6 +332,7 @@ public fun add_record( ctx, ); assert!(!locking::is_write_locked(&self.locking_config, clock), ETrailWriteLocked); + assert_record_tag_allowed(self, cap, &record_tag); let caller = ctx.sender(); let timestamp = clock::timestamp_ms(clock); @@ -289,6 +342,7 @@ public fun add_record( let record = record::new( stored_data, record_metadata, + record_tag, seq, caller, timestamp, @@ -418,6 +472,7 @@ public fun delete_audit_trail( created_at: _, sequence_number: _, records, + available_record_tags: _, locking_config: _, roles: _, immutable_metadata: _, @@ -554,6 +609,65 @@ public fun update_metadata( self.updatable_metadata = new_metadata; } +/// Adds a new record tag to the trail registry. +public fun add_available_record_tag( + self: &mut AuditTrail, + cap: &Capability, + tag: String, + clock: &Clock, + ctx: &TxContext, +) { + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + assert_record_tag_admin(self, cap, clock, ctx); + assert!(!iota::vec_set::contains(&self.available_record_tags, &tag), ERecordTagAlreadyDefined); + self.available_record_tags.insert(tag); +} + +/// Removes a record tag from the trail registry if it is not used by any record. +public fun remove_available_record_tag( + self: &mut AuditTrail, + cap: &Capability, + tag: String, + clock: &Clock, + ctx: &TxContext, +) { + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + assert_record_tag_admin(self, cap, clock, ctx); + assert!(iota::vec_set::contains(&self.available_record_tags, &tag), ERecordTagNotDefined); + assert!(!record_tags::is_in_use(&self.records, self.sequence_number, &tag), ERecordTagInUse); + self.available_record_tags.remove(&tag); +} + +/// Replaces the trail registry with a new set of available record tags. +public fun set_available_record_tags( + self: &mut AuditTrail, + cap: &Capability, + tags: vector, + clock: &Clock, + ctx: &TxContext, +) { + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + assert_record_tag_admin(self, cap, clock, ctx); + + let new_tags = iota::vec_set::from_keys(tags); + let existing_tags = iota::vec_set::keys(&self.available_record_tags); + let mut i = 0; + let existing_tag_count = existing_tags.length(); + + while (i < existing_tag_count) { + let existing_tag = &existing_tags[i]; + if (!iota::vec_set::contains(&new_tags, existing_tag)) { + assert!( + !record_tags::is_in_use(&self.records, self.sequence_number, existing_tag), + ERecordTagInUse, + ); + }; + i = i + 1; + }; + + self.available_record_tags = new_tags; +} + // ===== Role and Capability Administration ===== /// Creates a new role with the provided permissions. @@ -562,16 +676,18 @@ public fun create_role( cap: &Capability, role: String, permissions: VecSet, + record_tags: Option, clock: &Clock, ctx: &mut TxContext, ) { assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + assert_defined_record_tags(self, &record_tags); role_map::create_role( self.roles_mut(), cap, role, permissions, - std::option::none(), + record_tags, clock, ctx, ); @@ -583,16 +699,18 @@ public fun update_role_permissions( cap: &Capability, role: String, new_permissions: VecSet, + record_tags: Option, clock: &Clock, ctx: &mut TxContext, ) { assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + assert_defined_record_tags(self, &record_tags); role_map::update_role( self.roles_mut(), cap, &role, new_permissions, - std::option::none(), + record_tags, clock, ctx, ); @@ -762,6 +880,11 @@ public fun locking_config(self: &AuditTrail): &LockingConfig &self.locking_config } +/// Get the trail-defined record tags. +public fun available_record_tags(self: &AuditTrail): &VecSet { + &self.available_record_tags +} + /// Check if the trail is empty public fun is_empty(self: &AuditTrail): bool { linked_table::is_empty(&self.records) diff --git a/audit-trail-move/sources/record.move b/audit-trail-move/sources/record.move index 69ead0fd..5d45589e 100644 --- a/audit-trail-move/sources/record.move +++ b/audit-trail-move/sources/record.move @@ -16,6 +16,8 @@ public struct Record has store { data: D, /// Optional metadata for this specific record metadata: Option, + /// Optional immutable tag associated with this record + tag: Option, /// Position in the trail (0-indexed, never reused) sequence_number: u64, /// Who added this record @@ -32,6 +34,7 @@ public struct Record has store { public(package) fun new( data: D, metadata: Option, + tag: Option, sequence_number: u64, added_by: address, added_at: u64, @@ -40,6 +43,7 @@ public(package) fun new( Record { data, metadata, + tag, sequence_number, added_by, added_at, @@ -59,6 +63,11 @@ public fun metadata(record: &Record): &Option { &record.metadata } +/// Get the optional record tag +public fun tag(record: &Record): &Option { + &record.tag +} + /// Get the record sequence number public fun sequence_number(record: &Record): u64 { record.sequence_number @@ -84,6 +93,7 @@ public(package) fun destroy(record: Record) { let Record { data: _, metadata: _, + tag: _, sequence_number: _, added_by: _, added_at: _, diff --git a/audit-trail-move/sources/record_tags.move b/audit-trail-move/sources/record_tags.move new file mode 100644 index 00000000..2a07dc9a --- /dev/null +++ b/audit-trail-move/sources/record_tags.move @@ -0,0 +1,94 @@ +// Copyright (c) 2025 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +/// Record tag types and helper predicates for audit trails. +module audit_trail::record_tags; + +use audit_trail::{permission::Permission, record::{Self, Record}}; +use iota::{linked_table::{Self, LinkedTable}, vec_set::VecSet}; +use std::string::String; +use tf_components::{capability::Capability, role_map::{Self, RoleMap}}; + +/// Stores all record tag related data associated with a role in the RoleMap. +public struct RecordTags has copy, drop, store { + allowed_tags: VecSet, +} + +/// Create a role-scoped record-tag access list. +public fun new_record_tags(allowed_tags: vector): RecordTags { + RecordTags { + allowed_tags: iota::vec_set::from_keys(allowed_tags), + } +} + +/// Get the allowlisted record tags for a role. +public fun allowed_record_tags(record_tags: &RecordTags): &VecSet { + &record_tags.allowed_tags +} + +/// Returns true when all provided role tags are defined on the trail. +public(package) fun defined_for_trail( + available_tags: &VecSet, + record_tags: &Option, +): bool { + if (!record_tags.is_some()) { + return true + }; + + let allowed_tags = &option::borrow(record_tags).allowed_tags; + let allowed_tag_keys = iota::vec_set::keys(allowed_tags); + let mut i = 0; + let tag_count = allowed_tag_keys.length(); + + while (i < tag_count) { + if (!iota::vec_set::contains(available_tags, &allowed_tag_keys[i])) { + return false + }; + i = i + 1; + }; + + true +} + +/// Returns true when the requested tag exists in the trail registry. +public(package) fun is_defined(available_tags: &VecSet, tag: &String): bool { + iota::vec_set::contains(available_tags, tag) +} + +/// Returns true when the capability's role data allows the requested tag. +public(package) fun role_allows( + roles: &RoleMap, + cap: &Capability, + tag: &String, +): bool { + let role_tags = role_map::get_role_data(roles, cap.role()); + if (!role_tags.is_some()) { + return false + }; + + let allowed_tags = &option::borrow(role_tags).allowed_tags; + iota::vec_set::contains(allowed_tags, tag) +} + +/// Returns true when any live record currently uses the provided tag. +public(package) fun is_in_use( + records: &LinkedTable>, + sequence_number: u64, + tag: &String, +): bool { + let mut current_sequence = 0; + while (current_sequence < sequence_number) { + if (linked_table::contains(records, current_sequence)) { + let stored_record = linked_table::borrow(records, current_sequence); + let record_tag = record::tag(stored_record); + + if (record_tag.is_some() && option::borrow(record_tag) == tag) { + return true + }; + }; + + current_sequence = current_sequence + 1; + }; + + false +} diff --git a/audit-trail-move/tests/capability_tests.move b/audit-trail-move/tests/capability_tests.move index 50e9cc16..731d8f08 100644 --- a/audit-trail-move/tests/capability_tests.move +++ b/audit-trail-move/tests/capability_tests.move @@ -508,6 +508,7 @@ fun test_capability_lifecycle() { &record_cap, test_data, std::option::none(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -601,6 +602,7 @@ fun test_capability_issued_to_only() { &record_cap, test_data, std::option::none(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -624,6 +626,7 @@ fun test_capability_issued_to_only() { &record_cap, test_data, std::option::none(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -711,6 +714,7 @@ fun test_revoked_capability_cannot_be_used() { &user_cap, test_utils::new_test_data(1, b"Should fail"), std::option::none(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -991,6 +995,7 @@ fun test_capability_valid_from_only() { &cap, test_data, std::option::none(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -1010,6 +1015,7 @@ fun test_capability_valid_from_only() { &cap, test_data, std::option::none(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -1072,6 +1078,7 @@ fun test_capability_valid_until_only() { &cap, test_data, std::option::none(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -1091,6 +1098,7 @@ fun test_capability_valid_until_only() { &cap, test_data, std::option::none(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -1136,6 +1144,7 @@ fun test_capability_time_window() { &cap, test_data, std::option::none(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -1180,6 +1189,7 @@ fun test_capability_time_window_before_valid_from() { &cap, test_data, std::option::none(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -1224,6 +1234,7 @@ fun test_capability_time_window_after_valid_until() { &cap, test_data, std::option::none(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); diff --git a/audit-trail-move/tests/create_audit_trail_tests.move b/audit-trail-move/tests/create_audit_trail_tests.move index 70cca95e..283ed889 100644 --- a/audit-trail-move/tests/create_audit_trail_tests.move +++ b/audit-trail-move/tests/create_audit_trail_tests.move @@ -14,7 +14,7 @@ use audit_trail::{ cleanup_capability_trail_and_clock } }; -use iota::{clock, test_scenario as ts}; +use iota::{clock, test_scenario as ts, vec_set}; use std::string; use tf_components::timelock; @@ -125,6 +125,7 @@ fun test_create_minimal_metadata() { locking_config, option::none(), option::none(), + vector[], &clock, ts::ctx(&mut scenario), ); @@ -310,3 +311,55 @@ fun test_create_metadata_admin_role() { ts::end(scenario); } + +#[test] +fun test_manage_available_record_tags_roundtrip() { + let admin = @0xF; + let mut scenario = ts::begin(admin); + + { + let locking_config = locking::new( + locking::window_count_based(0), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + option::none(), + ); + transfer::public_transfer(admin_cap, admin); + }; + + ts::next_tx(&mut scenario, admin); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + trail.add_available_record_tag( + &admin_cap, + string::utf8(b"finance"), + &clock, + ts::ctx(&mut scenario), + ); + trail.set_available_record_tags( + &admin_cap, + vector[string::utf8(b"finance"), string::utf8(b"legal")], + &clock, + ts::ctx(&mut scenario), + ); + trail.remove_available_record_tag( + &admin_cap, + string::utf8(b"legal"), + &clock, + ts::ctx(&mut scenario), + ); + + let available_tags = trail.available_record_tags(); + assert!(vec_set::size(available_tags) == 1, 0); + assert!(vec_set::contains(available_tags, &string::utf8(b"finance")), 1); + + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + }; + + ts::end(scenario); +} diff --git a/audit-trail-move/tests/locking_tests.move b/audit-trail-move/tests/locking_tests.move index eca383a6..54b8b4be 100644 --- a/audit-trail-move/tests/locking_tests.move +++ b/audit-trail-move/tests/locking_tests.move @@ -173,6 +173,7 @@ fun test_count_based_locking() { &record_cap, new_test_data(i, b"Record"), std::option::none(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -746,6 +747,7 @@ fun test_time_based_locking_all_recent_records_locked() { &record_cap, new_test_data(i, b"Record"), std::option::none(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -832,6 +834,7 @@ fun test_count_based_locking_last_records_remain_locked() { &record_cap, new_test_data(i, b"Record"), std::option::none(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -920,6 +923,7 @@ fun test_time_based_locking_still_locked_before_expiry() { &record_cap, new_test_data(i, b"Record"), std::option::none(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -1004,6 +1008,7 @@ fun test_count_based_locking_old_record_can_delete() { &record_cap, new_test_data(i, b"Record"), std::option::none(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); diff --git a/audit-trail-move/tests/record_tests.move b/audit-trail-move/tests/record_tests.move index 400f2dfa..25acb386 100644 --- a/audit-trail-move/tests/record_tests.move +++ b/audit-trail-move/tests/record_tests.move @@ -6,10 +6,13 @@ use audit_trail::{ locking, main::{Self, AuditTrail}, permission, + record, + record_tags, test_utils::{ Self, TestData, setup_test_audit_trail, + setup_test_audit_trail_with_tags, new_test_data, initial_time_for_testing, test_data_value, @@ -89,6 +92,7 @@ fun test_add_record_to_empty_trail() { &record_cap, new_test_data(42, b"First record"), std::option::some(string::utf8(b"metadata")), + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -104,6 +108,295 @@ fun test_add_record_to_empty_trail() { ts::end(scenario); } +#[test] +fun test_add_tagged_record_with_matching_role_tags() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + { + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail_with_tags( + &mut scenario, + locking_config, + std::option::none(), + vector[string::utf8(b"finance")], + ); + transfer::public_transfer(admin_cap, admin); + }; + + ts::next_tx(&mut scenario, admin); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + trail.create_role( + &admin_cap, + string::utf8(b"TaggedWriter"), + permission::record_admin_permissions(), + std::option::some(record_tags::new_record_tags(vector[string::utf8(b"finance")])), + &clock, + ts::ctx(&mut scenario), + ); + + let record_cap = test_utils::new_capability_without_restrictions( + trail.roles_mut(), + &admin_cap, + &string::utf8(b"TaggedWriter"), + &clock, + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(record_cap, admin); + admin_cap.destroy_for_testing(); + cleanup_trail_and_clock(trail, clock); + }; + + ts::next_tx(&mut scenario, admin); + { + let (record_cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); + clock.set_for_testing(initial_time_for_testing() + 1000); + + trail.add_record( + &record_cap, + new_test_data(99, b"Tagged record"), + std::option::none(), + std::option::some(string::utf8(b"finance")), + &clock, + ts::ctx(&mut scenario), + ); + + let stored_record = trail.get_record(0); + assert!(*record::tag(stored_record) == std::option::some(string::utf8(b"finance")), 0); + + cleanup_capability_trail_and_clock(&scenario, record_cap, trail, clock); + }; + + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = audit_trail::main::ERecordTagNotAllowed)] +fun test_add_tagged_record_requires_matching_role_tags() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + { + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail_with_tags( + &mut scenario, + locking_config, + std::option::none(), + vector[string::utf8(b"finance")], + ); + transfer::public_transfer(admin_cap, admin); + }; + + ts::next_tx(&mut scenario, admin); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + trail + .roles_mut() + .create_role( + &admin_cap, + string::utf8(b"PlainWriter"), + permission::record_admin_permissions(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + let record_cap = test_utils::new_capability_without_restrictions( + trail.roles_mut(), + &admin_cap, + &string::utf8(b"PlainWriter"), + &clock, + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(record_cap, admin); + admin_cap.destroy_for_testing(); + cleanup_trail_and_clock(trail, clock); + }; + + ts::next_tx(&mut scenario, admin); + { + let (record_cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); + clock.set_for_testing(initial_time_for_testing() + 1000); + + trail.add_record( + &record_cap, + new_test_data(7, b"Denied tagged record"), + std::option::none(), + std::option::some(string::utf8(b"finance")), + &clock, + ts::ctx(&mut scenario), + ); + + cleanup_capability_trail_and_clock(&scenario, record_cap, trail, clock); + }; + + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = audit_trail::main::ERecordTagNotDefined)] +fun test_add_tagged_record_requires_trail_defined_tag() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + { + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail_with_tags( + &mut scenario, + locking_config, + std::option::none(), + vector[string::utf8(b"legal")], + ); + transfer::public_transfer(admin_cap, admin); + }; + + ts::next_tx(&mut scenario, admin); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + trail.create_role( + &admin_cap, + string::utf8(b"TaggedWriter"), + permission::record_admin_permissions(), + std::option::some(record_tags::new_record_tags(vector[string::utf8(b"finance")])), + &clock, + ts::ctx(&mut scenario), + ); + + let record_cap = test_utils::new_capability_without_restrictions( + trail.roles_mut(), + &admin_cap, + &string::utf8(b"TaggedWriter"), + &clock, + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(record_cap, admin); + admin_cap.destroy_for_testing(); + cleanup_trail_and_clock(trail, clock); + }; + + ts::next_tx(&mut scenario, admin); + { + let (record_cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); + clock.set_for_testing(initial_time_for_testing() + 1000); + + trail.add_record( + &record_cap, + new_test_data(77, b"Undefined tagged record"), + std::option::none(), + std::option::some(string::utf8(b"finance")), + &clock, + ts::ctx(&mut scenario), + ); + + cleanup_capability_trail_and_clock(&scenario, record_cap, trail, clock); + }; + + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = audit_trail::main::ERecordTagInUse)] +fun test_remove_available_record_tag_rejects_in_use_tag() { + let admin = @0xAD; + let writer = @0xB0B; + let mut scenario = ts::begin(admin); + + { + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail_with_tags( + &mut scenario, + locking_config, + std::option::none(), + vector[string::utf8(b"finance")], + ); + transfer::public_transfer(admin_cap, admin); + }; + + ts::next_tx(&mut scenario, admin); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + trail.create_role( + &admin_cap, + string::utf8(b"TaggedWriter"), + permission::record_admin_permissions(), + std::option::some(record_tags::new_record_tags(vector[string::utf8(b"finance")])), + &clock, + ts::ctx(&mut scenario), + ); + + let writer_cap = test_utils::new_capability_for_address( + trail.roles_mut(), + &admin_cap, + &string::utf8(b"TaggedWriter"), + writer, + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(writer_cap, writer); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + }; + + ts::next_tx(&mut scenario, writer); + { + let (writer_cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); + clock.set_for_testing(initial_time_for_testing() + 1000); + + trail.add_record( + &writer_cap, + new_test_data(55, b"Tagged"), + std::option::none(), + std::option::some(string::utf8(b"finance")), + &clock, + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(writer_cap, writer); + cleanup_trail_and_clock(trail, clock); + }; + + ts::next_tx(&mut scenario, admin); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + trail.remove_available_record_tag( + &admin_cap, + string::utf8(b"finance"), + &clock, + ts::ctx(&mut scenario), + ); + + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + }; + + ts::end(scenario); +} + #[test] fun test_add_multiple_records() { let admin = @0xAD; @@ -166,6 +459,7 @@ fun test_add_multiple_records() { &record_cap, new_test_data(i, b"Record"), std::option::none(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -247,6 +541,7 @@ fun test_add_record_permission_denied() { &no_add_cap, new_test_data(1, b"Should fail"), std::option::none(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -722,6 +1017,7 @@ fun test_first_last_sequence() { &record_cap, new_test_data(1, b"First"), std::option::none(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -734,6 +1030,7 @@ fun test_first_last_sequence() { &record_cap, new_test_data(2, b"Second"), std::option::none(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -746,6 +1043,7 @@ fun test_first_last_sequence() { &record_cap, new_test_data(3, b"Third"), std::option::none(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); diff --git a/audit-trail-move/tests/role_tests.move b/audit-trail-move/tests/role_tests.move index 2bb32ea8..506315a4 100644 --- a/audit-trail-move/tests/role_tests.move +++ b/audit-trail-move/tests/role_tests.move @@ -4,12 +4,14 @@ module audit_trail::role_tests; use audit_trail::{ locking, - main::{initial_admin_role_name, AuditTrail}, + main::{AuditTrail, initial_admin_role_name}, permission, + record_tags, test_utils::{ Self, TestData, setup_test_audit_trail, + setup_test_audit_trail_with_tags, fetch_capability_trail_and_clock, cleanup_capability_trail_and_clock } @@ -201,6 +203,7 @@ fun test_role_based_permission_delegation() { &record_admin_cap, test_data, std::option::none(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -216,6 +219,47 @@ fun test_role_based_permission_delegation() { ts::end(scenario); } +#[test] +#[expected_failure(abort_code = audit_trail::main::ERecordTagNotDefined)] +fun test_create_role_rejects_undefined_record_tags() { + let admin_user = @0xAD; + let mut scenario = ts::begin(admin_user); + + { + let locking_config = locking::new( + locking::window_count_based(0), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail_with_tags( + &mut scenario, + locking_config, + std::option::none(), + vector[string::utf8(b"legal")], + ); + transfer::public_transfer(admin_cap, admin_user); + }; + + ts::next_tx(&mut scenario, admin_user); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + let perms = permission::from_vec(vector[permission::add_record()]); + + trail.create_role( + &admin_cap, + string::utf8(b"TaggedRole"), + perms, + std::option::some(record_tags::new_record_tags(vector[string::utf8(b"finance")])), + &clock, + ts::ctx(&mut scenario), + ); + + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + }; + + ts::end(scenario); +} + #[test] fun test_delete_role_success() { let admin_user = @0xAD; @@ -633,6 +677,55 @@ fun test_update_role_permissions_success() { ts::end(scenario); } +#[test] +#[expected_failure(abort_code = audit_trail::main::ERecordTagNotDefined)] +fun test_update_role_permissions_rejects_undefined_record_tags() { + let admin_user = @0xAD; + let mut scenario = ts::begin(admin_user); + + { + let locking_config = locking::new( + locking::window_count_based(0), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail_with_tags( + &mut scenario, + locking_config, + std::option::none(), + vector[string::utf8(b"legal")], + ); + transfer::public_transfer(admin_cap, admin_user); + }; + + ts::next_tx(&mut scenario, admin_user); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + trail.create_role( + &admin_cap, + string::utf8(b"TestRole"), + permission::from_vec(vector[permission::add_record()]), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + trail.update_role_permissions( + &admin_cap, + string::utf8(b"TestRole"), + permission::from_vec(vector[permission::add_record()]), + std::option::some(record_tags::new_record_tags(vector[string::utf8(b"finance")])), + &clock, + ts::ctx(&mut scenario), + ); + + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + }; + + ts::end(scenario); +} + #[test] #[expected_failure(abort_code = audit_trail::role_map::ERoleDoesNotExist)] fun test_update_role_permissions_nonexistent() { diff --git a/audit-trail-move/tests/test_utils.move b/audit-trail-move/tests/test_utils.move index 696dbe12..443af919 100644 --- a/audit-trail-move/tests/test_utils.move +++ b/audit-trail-move/tests/test_utils.move @@ -38,6 +38,16 @@ public(package) fun setup_test_audit_trail( scenario: &mut Scenario, locking_config: locking::LockingConfig, initial_data: Option, +): (Capability, iota::object::ID) { + setup_test_audit_trail_with_tags(scenario, locking_config, initial_data, vector[]) +} + +/// Setup a test audit trail with optional initial data and available record tags. +public(package) fun setup_test_audit_trail_with_tags( + scenario: &mut Scenario, + locking_config: locking::LockingConfig, + initial_data: Option, + available_record_tags: vector, ): (Capability, iota::object::ID) { let (admin_cap, trail_id) = { let mut clock = clock::create_for_testing(ts::ctx(scenario)); @@ -54,6 +64,7 @@ public(package) fun setup_test_audit_trail( locking_config, option::some(trail_metadata), option::none(), + available_record_tags, &clock, ts::ctx(scenario), ); diff --git a/audit-trail-rs/src/core/builder.rs b/audit-trail-rs/src/core/builder.rs index 1d6e070a..c256d219 100644 --- a/audit-trail-rs/src/core/builder.rs +++ b/audit-trail-rs/src/core/builder.rs @@ -3,6 +3,8 @@ //! Audit trail builder for creation transactions. +use std::collections::HashSet; + use iota_sdk::types::base_types::IotaAddress; use product_common::transaction::transaction_builder::TransactionBuilder; @@ -18,6 +20,7 @@ pub struct AuditTrailBuilder { pub locking_config: LockingConfig, pub trail_metadata: Option, pub updatable_metadata: Option, + pub available_record_tags: HashSet, } impl AuditTrailBuilder { @@ -55,6 +58,16 @@ impl AuditTrailBuilder { self } + /// Sets the canonical list of tags that may be used on records in this trail. + pub fn with_available_record_tags(mut self, tags: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.available_record_tags = tags.into_iter().map(Into::into).collect(); + self + } + /// Sets the admin address that receives the initial admin capability. pub fn with_admin(mut self, admin: IotaAddress) -> Self { self.admin = Some(admin); diff --git a/audit-trail-rs/src/core/create/operations.rs b/audit-trail-rs/src/core/create/operations.rs index 9fd813d8..e35a9baa 100644 --- a/audit-trail-rs/src/core/create/operations.rs +++ b/audit-trail-rs/src/core/create/operations.rs @@ -22,6 +22,7 @@ impl CreateOps { locking_config: LockingConfig, trail_metadata: Option, updatable_metadata: Option, + available_record_tags: impl IntoIterator, ) -> Result { let mut ptb = ProgrammableTransactionBuilder::new(); @@ -49,6 +50,9 @@ impl CreateOps { }; let updatable_metadata = utils::ptb_pure(&mut ptb, "updatable_metadata", updatable_metadata)?; + let mut available_record_tags = available_record_tags.into_iter().collect::>(); + available_record_tags.sort(); + let available_record_tags = utils::ptb_pure(&mut ptb, "available_record_tags", available_record_tags)?; let clock = utils::get_clock_ref(&mut ptb); let result = ptb.programmable_move_call( @@ -62,6 +66,7 @@ impl CreateOps { locking_config, trail_metadata, updatable_metadata, + available_record_tags, clock, ], ); diff --git a/audit-trail-rs/src/core/create/transactions.rs b/audit-trail-rs/src/core/create/transactions.rs index 9b714353..1223f06f 100644 --- a/audit-trail-rs/src/core/create/transactions.rs +++ b/audit-trail-rs/src/core/create/transactions.rs @@ -62,6 +62,7 @@ impl CreateTrail { locking_config, trail_metadata, updatable_metadata, + available_record_tags, } = self.builder.clone(); let admin = admin.ok_or_else(|| { @@ -81,6 +82,7 @@ impl CreateTrail { locking_config, trail_metadata, updatable_metadata, + available_record_tags, ) } } diff --git a/audit-trail-rs/src/core/operations.rs b/audit-trail-rs/src/core/operations.rs index 8b4bb642..20fa30c0 100644 --- a/audit-trail-rs/src/core/operations.rs +++ b/audit-trail-rs/src/core/operations.rs @@ -59,7 +59,7 @@ where /// Finds a capability owned by `owner` whose role has the required permission /// according to the trail's RoleMap. -async fn find_capable_cap( +pub(crate) async fn find_capable_cap( client: &C, owner: IotaAddress, trail_id: ObjectID, @@ -73,7 +73,7 @@ where .roles .roles .iter() - .filter(|(_, perms)| perms.contains(&permission)) + .filter(|(_, role)| role.permissions.contains(&permission)) .map(|(name, _)| name) .collect(); diff --git a/audit-trail-rs/src/core/records/mod.rs b/audit-trail-rs/src/core/records/mod.rs index 7509b050..bd7db2df 100644 --- a/audit-trail-rs/src/core/records/mod.rs +++ b/audit-trail-rs/src/core/records/mod.rs @@ -53,14 +53,14 @@ impl<'a, C, D> TrailRecords<'a, C, D> { self.client.execute_read_only_transaction(tx).await } - pub fn add(&self, data: D, metadata: Option) -> TransactionBuilder + pub fn add(&self, data: D, metadata: Option, tag: Option) -> TransactionBuilder where C: AuditTrailFull + CoreClient, S: Signer + OptionalSync, D: Into, { let owner = self.client.sender_address(); - TransactionBuilder::new(AddRecord::new(self.trail_id, owner, data.into(), metadata)) + TransactionBuilder::new(AddRecord::new(self.trail_id, owner, data.into(), metadata, tag)) } pub fn delete(&self, sequence_number: u64) -> TransactionBuilder diff --git a/audit-trail-rs/src/core/records/operations.rs b/audit-trail-rs/src/core/records/operations.rs index d2efc31c..c019943a 100644 --- a/audit-trail-rs/src/core/records/operations.rs +++ b/audit-trail-rs/src/core/records/operations.rs @@ -19,26 +19,60 @@ impl RecordsOps { owner: IotaAddress, data: Data, record_metadata: Option, + record_tag: Option, ) -> Result where C: CoreClientReadOnly + OptionalSync, { - operations::build_trail_transaction( - client, - trail_id, - owner, - Permission::AddRecord, - "add_record", - |ptb, trail_tag| { - data.ensure_matches_tag(trail_tag)?; + if let Some(tag) = record_tag.clone() { + let trail = operations::get_audit_trail(trail_id, client).await?; + if !trail + .available_record_tags + .contents + .iter() + .any(|allowed_tag| allowed_tag == &tag) + { + return Err(Error::InvalidArgument(format!( + "record tag '{tag}' is not defined for trail {trail_id}" + ))); + } + let cap_ref = find_capable_cap_for_tag(client, owner, trail_id, &trail, &tag).await?; - let data_arg = data.to_ptb(ptb, "stored_data")?; - let metadata = utils::ptb_pure(ptb, "record_metadata", record_metadata)?; - let clock = utils::get_clock_ref(ptb); - Ok(vec![data_arg, metadata, clock]) - }, - ) - .await + operations::build_trail_transaction_with_cap_ref( + client, + trail_id, + cap_ref, + "add_record", + |ptb, trail_tag| { + data.ensure_matches_tag(trail_tag)?; + + let data_arg = data.to_ptb(ptb, "stored_data")?; + let metadata = utils::ptb_pure(ptb, "record_metadata", record_metadata)?; + let tag_arg = utils::ptb_pure(ptb, "record_tag", Some(tag))?; + let clock = utils::get_clock_ref(ptb); + Ok(vec![data_arg, metadata, tag_arg, clock]) + }, + ) + .await + } else { + operations::build_trail_transaction( + client, + trail_id, + owner, + Permission::AddRecord, + "add_record", + |ptb, trail_tag| { + data.ensure_matches_tag(trail_tag)?; + + let data_arg = data.to_ptb(ptb, "stored_data")?; + let metadata = utils::ptb_pure(ptb, "record_metadata", record_metadata)?; + let tag = utils::ptb_pure(ptb, "record_tag", Option::::None)?; + let clock = utils::get_clock_ref(ptb); + Ok(vec![data_arg, metadata, tag, clock]) + }, + ) + .await + } } pub(super) async fn delete_record( @@ -111,3 +145,41 @@ impl RecordsOps { operations::build_read_only_transaction(client, trail_id, "record_count", |_| Ok(vec![])).await } } + +async fn find_capable_cap_for_tag( + client: &C, + owner: IotaAddress, + trail_id: ObjectID, + trail: &crate::core::types::OnChainAuditTrail, + tag: &str, +) -> Result +where + C: CoreClientReadOnly + OptionalSync, +{ + let valid_roles = trail + .roles + .roles + .iter() + .filter(|(_, role)| { + role.permissions.contains(&Permission::AddRecord) + && role.data.as_ref().is_some_and(|record_tags| record_tags.allows(tag)) + }) + .map(|(name, _)| name.clone()) + .collect::>(); + + let cap = client + .find_object_for_address(owner, |cap: &crate::core::types::Capability| { + cap.target_key == trail_id && valid_roles.contains(&cap.role) + }) + .await + .map_err(|e| Error::RpcError(e.to_string()))? + .ok_or_else(|| { + Error::InvalidArgument(format!( + "no capability with {:?} permission and record tag '{tag}' found for owner {owner} and trail {trail_id}", + Permission::AddRecord + )) + })?; + + let object_id = *cap.id.object_id(); + utils::get_object_ref_by_id(client, &object_id).await +} diff --git a/audit-trail-rs/src/core/records/transactions.rs b/audit-trail-rs/src/core/records/transactions.rs index d8bd6d83..74007aa2 100644 --- a/audit-trail-rs/src/core/records/transactions.rs +++ b/audit-trail-rs/src/core/records/transactions.rs @@ -22,16 +22,24 @@ pub struct AddRecord { pub owner: IotaAddress, pub data: Data, pub metadata: Option, + pub tag: Option, cached_ptb: OnceCell, } impl AddRecord { - pub fn new(trail_id: ObjectID, owner: IotaAddress, data: Data, metadata: Option) -> Self { + pub fn new( + trail_id: ObjectID, + owner: IotaAddress, + data: Data, + metadata: Option, + tag: Option, + ) -> Self { Self { trail_id, owner, data, metadata, + tag, cached_ptb: OnceCell::new(), } } @@ -46,6 +54,7 @@ impl AddRecord { self.owner, self.data.clone(), self.metadata.clone(), + self.tag.clone(), ) .await } diff --git a/audit-trail-rs/src/core/roles/mod.rs b/audit-trail-rs/src/core/roles/mod.rs index b92d7067..c1caf508 100644 --- a/audit-trail-rs/src/core/roles/mod.rs +++ b/audit-trail-rs/src/core/roles/mod.rs @@ -8,7 +8,7 @@ use product_common::transaction::transaction_builder::TransactionBuilder; use secret_storage::Signer; use crate::core::trail::AuditTrailFull; -use crate::core::types::{CapabilityIssueOptions, PermissionSet}; +use crate::core::types::{CapabilityIssueOptions, PermissionSet, RecordTags}; mod operations; mod transactions; @@ -96,14 +96,24 @@ impl<'a, C> RoleHandle<'a, C> { &self.name } - /// Creates this role with the provided permissions. - pub fn create(&self, permissions: PermissionSet) -> TransactionBuilder + /// Creates this role with the provided permissions and optional record-tag access rules. + pub fn create( + &self, + permissions: PermissionSet, + record_tags: Option, + ) -> TransactionBuilder where C: AuditTrailFull + CoreClient, S: Signer + OptionalSync, { let owner = self.client.sender_address(); - TransactionBuilder::new(CreateRole::new(self.trail_id, owner, self.name.clone(), permissions)) + TransactionBuilder::new(CreateRole::new( + self.trail_id, + owner, + self.name.clone(), + permissions, + record_tags, + )) } /// Issues a capability for this role using optional restrictions. @@ -116,14 +126,24 @@ impl<'a, C> RoleHandle<'a, C> { TransactionBuilder::new(IssueCapability::new(self.trail_id, owner, self.name.clone(), options)) } - /// Updates permissions for this role. - pub fn update_permissions(&self, permissions: PermissionSet) -> TransactionBuilder + /// Updates permissions and record-tag access rules for this role. + pub fn update_permissions( + &self, + permissions: PermissionSet, + record_tags: Option, + ) -> TransactionBuilder where C: AuditTrailFull + CoreClient, S: Signer + OptionalSync, { let owner = self.client.sender_address(); - TransactionBuilder::new(UpdateRole::new(self.trail_id, owner, self.name.clone(), permissions)) + TransactionBuilder::new(UpdateRole::new( + self.trail_id, + owner, + self.name.clone(), + permissions, + record_tags, + )) } /// Deletes this role. diff --git a/audit-trail-rs/src/core/roles/operations.rs b/audit-trail-rs/src/core/roles/operations.rs index 2eac878e..620fb1e2 100644 --- a/audit-trail-rs/src/core/roles/operations.rs +++ b/audit-trail-rs/src/core/roles/operations.rs @@ -6,7 +6,7 @@ use iota_interaction::types::transaction::{ObjectArg, ProgrammableTransaction}; use iota_interaction::{OptionalSync, ident_str}; use product_common::core_client::CoreClientReadOnly; -use crate::core::types::{CapabilityIssueOptions, Permission, PermissionSet}; +use crate::core::types::{CapabilityIssueOptions, Permission, PermissionSet, RecordTags}; use crate::core::{operations, utils}; use crate::error::Error; @@ -19,10 +19,13 @@ impl RolesOps { owner: IotaAddress, name: String, permissions: PermissionSet, + record_tags: Option, ) -> Result where C: CoreClientReadOnly + OptionalSync, { + assert_record_tags_defined(client, trail_id, &record_tags).await?; + operations::build_trail_transaction( client, trail_id, @@ -39,9 +42,19 @@ impl RolesOps { vec![], vec![perms_vec], ); + let record_tags_arg = match record_tags { + Some(record_tags) => { + let record_tags_arg = record_tags.to_ptb(ptb, client.package_id())?; + + utils::option_to_move(Some(record_tags_arg), RecordTags::tag(client.package_id()), ptb) + .map_err(|e| Error::InvalidArgument(format!("failed to build record_tags option: {e}")))? + } + None => utils::option_to_move(None, RecordTags::tag(client.package_id()), ptb) + .map_err(|e| Error::InvalidArgument(format!("failed to build record_tags option: {e}")))?, + }; let clock = utils::get_clock_ref(ptb); - Ok(vec![role, perms, clock]) + Ok(vec![role, perms, record_tags_arg, clock]) }, ) .await @@ -53,10 +66,13 @@ impl RolesOps { owner: IotaAddress, name: String, permissions: PermissionSet, + record_tags: Option, ) -> Result where C: CoreClientReadOnly + OptionalSync, { + assert_record_tags_defined(client, trail_id, &record_tags).await?; + operations::build_trail_transaction( client, trail_id, @@ -74,10 +90,19 @@ impl RolesOps { vec![], vec![perms_vec], ); + let record_tags_arg = match record_tags { + Some(record_tags) => { + let record_tags_arg = record_tags.to_ptb(ptb, client.package_id())?; + utils::option_to_move(Some(record_tags_arg), RecordTags::tag(client.package_id()), ptb) + .map_err(|e| Error::InvalidArgument(format!("failed to build record_tags option: {e}")))? + } + None => utils::option_to_move(None, RecordTags::tag(client.package_id()), ptb) + .map_err(|e| Error::InvalidArgument(format!("failed to build record_tags option: {e}")))?, + }; let clock = utils::get_clock_ref(ptb); - Ok(vec![role, perms, clock]) + Ok(vec![role, perms, record_tags_arg, clock]) }, ) .await @@ -235,3 +260,39 @@ impl RolesOps { .await } } + +async fn assert_record_tags_defined( + client: &C, + trail_id: ObjectID, + record_tags: &Option, +) -> Result<(), Error> +where + C: CoreClientReadOnly + OptionalSync, +{ + let Some(record_tags) = record_tags else { + return Ok(()); + }; + + let trail = operations::get_audit_trail(trail_id, client).await?; + let undefined_tags = record_tags + .allowed_tags + .iter() + .filter(|tag| { + !trail + .available_record_tags + .contents + .iter() + .any(|available_tag| available_tag == *tag) + }) + .cloned() + .collect::>(); + + if undefined_tags.is_empty() { + Ok(()) + } else { + Err(Error::InvalidArgument(format!( + "record tags {:?} are not defined for trail {trail_id}", + undefined_tags + ))) + } +} diff --git a/audit-trail-rs/src/core/roles/transactions.rs b/audit-trail-rs/src/core/roles/transactions.rs index 3bfc2ead..b8545519 100644 --- a/audit-trail-rs/src/core/roles/transactions.rs +++ b/audit-trail-rs/src/core/roles/transactions.rs @@ -12,7 +12,7 @@ use tokio::sync::OnceCell; use super::operations::RolesOps; use crate::core::types::{ - CapabilityDestroyed, CapabilityIssueOptions, CapabilityIssued, CapabilityRevoked, Event, PermissionSet, + CapabilityDestroyed, CapabilityIssueOptions, CapabilityIssued, CapabilityRevoked, Event, PermissionSet, RecordTags, RoleCreated, RoleRemoved, RoleUpdated, }; use crate::error::Error; @@ -25,16 +25,24 @@ pub struct CreateRole { owner: IotaAddress, name: String, permissions: PermissionSet, + record_tags: Option, cached_ptb: OnceCell, } impl CreateRole { - pub fn new(trail_id: ObjectID, owner: IotaAddress, name: String, permissions: PermissionSet) -> Self { + pub fn new( + trail_id: ObjectID, + owner: IotaAddress, + name: String, + permissions: PermissionSet, + record_tags: Option, + ) -> Self { Self { trail_id, owner, name, permissions, + record_tags, cached_ptb: OnceCell::new(), } } @@ -49,6 +57,7 @@ impl CreateRole { self.owner, self.name.clone(), self.permissions.clone(), + self.record_tags.clone(), ) .await } @@ -99,16 +108,24 @@ pub struct UpdateRole { owner: IotaAddress, name: String, permissions: PermissionSet, + record_tags: Option, cached_ptb: OnceCell, } impl UpdateRole { - pub fn new(trail_id: ObjectID, owner: IotaAddress, name: String, permissions: PermissionSet) -> Self { + pub fn new( + trail_id: ObjectID, + owner: IotaAddress, + name: String, + permissions: PermissionSet, + record_tags: Option, + ) -> Self { Self { trail_id, owner, name, permissions, + record_tags, cached_ptb: OnceCell::new(), } } @@ -123,6 +140,7 @@ impl UpdateRole { self.owner, self.name.clone(), self.permissions.clone(), + self.record_tags.clone(), ) .await } diff --git a/audit-trail-rs/src/core/trail.rs b/audit-trail-rs/src/core/trail.rs index e3ffa157..e4e70398 100644 --- a/audit-trail-rs/src/core/trail.rs +++ b/audit-trail-rs/src/core/trail.rs @@ -18,7 +18,7 @@ use crate::error::Error; mod operations; mod transactions; -pub use transactions::{DeleteAuditTrail, Migrate, UpdateMetadata}; +pub use transactions::{AddRecordTag, DeleteAuditTrail, Migrate, RemoveRecordTag, SetRecordTags, UpdateMetadata}; /// Marker trait for read-only audit trail clients. #[doc(hidden)] @@ -84,6 +84,42 @@ impl<'a, C> AuditTrailHandle<'a, C> { TransactionBuilder::new(DeleteAuditTrail::new(self.trail_id, owner)) } + /// Adds a tag to the trail-owned record-tag registry. + pub fn add_record_tag(&self, tag: impl Into) -> TransactionBuilder + where + C: AuditTrailFull + CoreClient, + S: Signer + OptionalSync, + { + let owner = self.client.sender_address(); + TransactionBuilder::new(AddRecordTag::new(self.trail_id, owner, tag.into())) + } + + /// Removes a tag from the trail-owned record-tag registry. + pub fn remove_record_tag(&self, tag: impl Into) -> TransactionBuilder + where + C: AuditTrailFull + CoreClient, + S: Signer + OptionalSync, + { + let owner = self.client.sender_address(); + TransactionBuilder::new(RemoveRecordTag::new(self.trail_id, owner, tag.into())) + } + + /// Replaces the entire trail-owned record-tag registry. + pub fn set_record_tags(&self, tags: I) -> TransactionBuilder + where + C: AuditTrailFull + CoreClient, + S: Signer + OptionalSync, + I: IntoIterator, + T: Into, + { + let owner = self.client.sender_address(); + TransactionBuilder::new(SetRecordTags::new( + self.trail_id, + owner, + tags.into_iter().map(Into::into).collect(), + )) + } + pub fn records(&self) -> TrailRecords<'a, C, Data> { TrailRecords::new(self.client, self.trail_id) } diff --git a/audit-trail-rs/src/core/trail/operations.rs b/audit-trail-rs/src/core/trail/operations.rs index d5e9bca0..98355b85 100644 --- a/audit-trail-rs/src/core/trail/operations.rs +++ b/audit-trail-rs/src/core/trail/operations.rs @@ -2,11 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 use iota_interaction::OptionalSync; -use iota_interaction::types::base_types::{IotaAddress, ObjectID}; +use iota_interaction::types::base_types::{IotaAddress, ObjectID, ObjectRef}; use iota_interaction::types::transaction::ProgrammableTransaction; use product_common::core_client::CoreClientReadOnly; -use crate::core::types::Permission; +use crate::core::types::{Capability, Permission}; use crate::core::{operations, utils}; use crate::error::Error; @@ -73,4 +73,102 @@ impl TrailOps { ) .await } + + pub(super) async fn add_record_tag( + client: &C, + trail_id: ObjectID, + owner: IotaAddress, + tag: String, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + build_admin_trail_transaction(client, trail_id, owner, "add_available_record_tag", |ptb| { + let tag_arg = utils::ptb_pure(ptb, "tag", tag)?; + let clock = utils::get_clock_ref(ptb); + Ok(vec![tag_arg, clock]) + }) + .await + } + + pub(super) async fn remove_record_tag( + client: &C, + trail_id: ObjectID, + owner: IotaAddress, + tag: String, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + build_admin_trail_transaction(client, trail_id, owner, "remove_available_record_tag", |ptb| { + let tag_arg = utils::ptb_pure(ptb, "tag", tag)?; + let clock = utils::get_clock_ref(ptb); + Ok(vec![tag_arg, clock]) + }) + .await + } + + pub(super) async fn set_record_tags( + client: &C, + trail_id: ObjectID, + owner: IotaAddress, + tags: Vec, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + build_admin_trail_transaction(client, trail_id, owner, "set_available_record_tags", |ptb| { + let tags_arg = utils::ptb_pure(ptb, "tags", tags)?; + let clock = utils::get_clock_ref(ptb); + Ok(vec![tags_arg, clock]) + }) + .await + } +} + +async fn build_admin_trail_transaction( + client: &C, + trail_id: ObjectID, + owner: IotaAddress, + method: impl AsRef, + additional_args: F, +) -> Result +where + F: FnOnce( + &mut iota_interaction::types::programmable_transaction_builder::ProgrammableTransactionBuilder, + ) -> Result, Error>, + C: CoreClientReadOnly + OptionalSync, +{ + let trail = operations::get_audit_trail(trail_id, client).await?; + let admin_cap_ref = find_admin_cap(client, owner, trail_id, &trail.roles.initial_admin_role_name).await?; + + operations::build_trail_transaction_with_cap_ref(client, trail_id, admin_cap_ref, method, |ptb, _| { + additional_args(ptb) + }) + .await +} + +async fn find_admin_cap( + client: &C, + owner: IotaAddress, + trail_id: ObjectID, + admin_role_name: &str, +) -> Result +where + C: CoreClientReadOnly + OptionalSync, +{ + let cap: Capability = client + .find_object_for_address(owner, |cap: &Capability| { + cap.target_key == trail_id && cap.role == admin_role_name + }) + .await + .map_err(|e| Error::RpcError(e.to_string()))? + .ok_or_else(|| { + Error::InvalidArgument(format!( + "no Admin capability found for owner {owner} and trail {trail_id}" + )) + })?; + + let object_id = *cap.id.object_id(); + utils::get_object_ref_by_id(client, &object_id).await } diff --git a/audit-trail-rs/src/core/trail/transactions.rs b/audit-trail-rs/src/core/trail/transactions.rs index 4f12359c..2b55c6d5 100644 --- a/audit-trail-rs/src/core/trail/transactions.rs +++ b/audit-trail-rs/src/core/trail/transactions.rs @@ -106,6 +106,147 @@ impl Transaction for UpdateMetadata { } } +#[derive(Debug, Clone)] +pub struct AddRecordTag { + trail_id: ObjectID, + owner: IotaAddress, + tag: String, + cached_ptb: OnceCell, +} + +impl AddRecordTag { + pub fn new(trail_id: ObjectID, owner: IotaAddress, tag: String) -> Self { + Self { + trail_id, + owner, + tag, + cached_ptb: OnceCell::new(), + } + } + + async fn make_ptb(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + TrailOps::add_record_tag(client, self.trail_id, self.owner, self.tag.clone()).await + } +} + +#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] +#[cfg_attr(feature = "send-sync", async_trait)] +impl Transaction for AddRecordTag { + type Error = Error; + type Output = (); + + async fn build_programmable_transaction(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() + } + + async fn apply(self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + Ok(()) + } +} + +#[derive(Debug, Clone)] +pub struct RemoveRecordTag { + trail_id: ObjectID, + owner: IotaAddress, + tag: String, + cached_ptb: OnceCell, +} + +impl RemoveRecordTag { + pub fn new(trail_id: ObjectID, owner: IotaAddress, tag: String) -> Self { + Self { + trail_id, + owner, + tag, + cached_ptb: OnceCell::new(), + } + } + + async fn make_ptb(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + TrailOps::remove_record_tag(client, self.trail_id, self.owner, self.tag.clone()).await + } +} + +#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] +#[cfg_attr(feature = "send-sync", async_trait)] +impl Transaction for RemoveRecordTag { + type Error = Error; + type Output = (); + + async fn build_programmable_transaction(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() + } + + async fn apply(self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + Ok(()) + } +} + +#[derive(Debug, Clone)] +pub struct SetRecordTags { + trail_id: ObjectID, + owner: IotaAddress, + tags: Vec, + cached_ptb: OnceCell, +} + +impl SetRecordTags { + pub fn new(trail_id: ObjectID, owner: IotaAddress, tags: Vec) -> Self { + Self { + trail_id, + owner, + tags, + cached_ptb: OnceCell::new(), + } + } + + async fn make_ptb(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + TrailOps::set_record_tags(client, self.trail_id, self.owner, self.tags.clone()).await + } +} + +#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] +#[cfg_attr(feature = "send-sync", async_trait)] +impl Transaction for SetRecordTags { + type Error = Error; + type Output = (); + + async fn build_programmable_transaction(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() + } + + async fn apply(self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + Ok(()) + } +} + #[derive(Debug, Clone)] pub struct DeleteAuditTrail { trail_id: ObjectID, diff --git a/audit-trail-rs/src/core/types/audit_trail.rs b/audit-trail-rs/src/core/types/audit_trail.rs index 1c408dfa..85210b66 100644 --- a/audit-trail-rs/src/core/types/audit_trail.rs +++ b/audit-trail-rs/src/core/types/audit_trail.rs @@ -6,7 +6,7 @@ use std::str::FromStr; use iota_interaction::ident_str; use iota_interaction::types::TypeTag; use iota_interaction::types::base_types::{IotaAddress, ObjectID}; -use iota_interaction::types::collection_types::LinkedTable; +use iota_interaction::types::collection_types::{LinkedTable, VecSet}; use iota_interaction::types::id::UID; use iota_interaction::types::programmable_transaction_builder::ProgrammableTransactionBuilder as Ptb; use iota_interaction::types::transaction::Argument; @@ -25,6 +25,7 @@ pub struct OnChainAuditTrail { pub created_at: u64, pub sequence_number: u64, pub records: LinkedTable, + pub available_record_tags: VecSet, pub locking_config: LockingConfig, pub roles: RoleMap, pub immutable_metadata: Option, diff --git a/audit-trail-rs/src/core/types/record.rs b/audit-trail-rs/src/core/types/record.rs index 2c6392a8..db472c34 100644 --- a/audit-trail-rs/src/core/types/record.rs +++ b/audit-trail-rs/src/core/types/record.rs @@ -26,6 +26,7 @@ pub struct PaginatedRecord { pub struct Record { pub data: D, pub metadata: Option, + pub tag: Option, pub sequence_number: u64, pub added_by: IotaAddress, pub added_at: u64, diff --git a/audit-trail-rs/src/core/types/role_map.rs b/audit-trail-rs/src/core/types/role_map.rs index 1f4a3072..de45d150 100644 --- a/audit-trail-rs/src/core/types/role_map.rs +++ b/audit-trail-rs/src/core/types/role_map.rs @@ -5,19 +5,25 @@ use std::collections::{HashMap, HashSet}; use std::str::FromStr; use iota_interaction::MoveType; +use iota_interaction::ident_str; use iota_interaction::types::TypeTag; use iota_interaction::types::base_types::{IotaAddress, ObjectID}; use iota_interaction::types::id::UID; +use iota_interaction::types::programmable_transaction_builder::ProgrammableTransactionBuilder as Ptb; +use iota_interaction::types::transaction::Argument; use serde::{Deserialize, Serialize}; use super::permission::Permission; +use crate::core::utils; use crate::core::utils::{deserialize_vec_map, deserialize_vec_set}; +use crate::error::Error; +use crate::package; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RoleMap { pub target_key: ObjectID, #[serde(deserialize_with = "deserialize_vec_map")] - pub roles: HashMap>, + pub roles: HashMap, pub initial_admin_role_name: String, #[serde(deserialize_with = "deserialize_vec_set")] pub issued_capabilities: HashSet, @@ -27,6 +33,13 @@ pub struct RoleMap { pub capability_admin_permissions: CapabilityAdminPermissions, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Role { + #[serde(deserialize_with = "deserialize_vec_set")] + pub permissions: HashSet, + pub data: Option, +} + /// Defines the permissions required to administer roles in this RoleMap. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RoleAdminPermissions { @@ -50,6 +63,47 @@ pub struct CapabilityIssueOptions { pub valid_until_ms: Option, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct RecordTags { + #[serde(deserialize_with = "deserialize_vec_set")] + pub allowed_tags: HashSet, +} + +impl RecordTags { + pub fn new(allowed_tags: I) -> Self + where + I: IntoIterator, + S: Into, + { + Self { + allowed_tags: allowed_tags.into_iter().map(Into::into).collect(), + } + } + + pub fn allows(&self, tag: &str) -> bool { + self.allowed_tags.contains(tag) + } + + pub(crate) fn tag(package_id: ObjectID) -> TypeTag { + TypeTag::from_str(&format!("{package_id}::record_tags::RecordTags")) + .expect("invalid TypeTag for RecordTags") + } + + pub(in crate::core) fn to_ptb(&self, ptb: &mut Ptb, package_id: ObjectID) -> Result { + let mut allowed_tags = self.allowed_tags.iter().cloned().collect::>(); + allowed_tags.sort(); + let allowed_tags_arg = utils::ptb_pure(ptb, "allowed_tags", allowed_tags)?; + + Ok(ptb.programmable_move_call( + package_id, + ident_str!("record_tags").into(), + ident_str!("new_record_tags").into(), + vec![], + vec![allowed_tags_arg], + )) + } +} + /// Capability data returned by the Move capability module. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Capability { @@ -62,7 +116,8 @@ pub struct Capability { } impl MoveType for Capability { - fn move_type(object_id: ObjectID) -> TypeTag { + fn move_type(_: ObjectID) -> TypeTag { + let object_id = package::tf_components_package_id(); TypeTag::from_str(format!("{object_id}::capability::Capability").as_str()).expect("failed to create type tag") } } diff --git a/audit-trail-rs/src/package.rs b/audit-trail-rs/src/package.rs index f9acec41..7fc2021d 100644 --- a/audit-trail-rs/src/package.rs +++ b/audit-trail-rs/src/package.rs @@ -33,7 +33,7 @@ static AUDIT_TRAIL_PACKAGE_REGISTRY: LazyLock> = LazyLoc /// Hardcoded TfComponents package ID used for timelock constructors. /// /// Update this value after publishing TfComponents. -const TF_COMPONENTS_PACKAGE_ID: &str = "0x5deb1782f8f078d7d85640099466c6513bee3ac261555fb06cb0bbe1f838ab17"; +const TF_COMPONENTS_PACKAGE_ID: &str = "0xd1992b10e1e62fd934e0ea993e95d475a2e56e47c6317db033c8ab7f26477ab6"; /// Returns a read lock to the package registry. pub(crate) async fn audit_trail_package_registry() -> PackageRegistryLock { diff --git a/audit-trail-rs/tests/e2e/client.rs b/audit-trail-rs/tests/e2e/client.rs index 4a5cb775..36f9955f 100644 --- a/audit-trail-rs/tests/e2e/client.rs +++ b/audit-trail-rs/tests/e2e/client.rs @@ -7,7 +7,7 @@ use std::sync::Arc; use audit_trails::AuditTrailClient; use audit_trails::core::types::{ - Capability, CapabilityIssueOptions, CapabilityIssued, Data, Permission, PermissionSet, RoleCreated, + Capability, CapabilityIssueOptions, CapabilityIssued, Data, Permission, PermissionSet, RecordTags, RoleCreated, }; use iota_interaction::types::base_types::{IotaAddress, ObjectID}; use iota_interaction::types::crypto::PublicKey; @@ -86,9 +86,20 @@ impl TestClient { /// Creates a trail with the given initial record data and returns its ObjectID. pub(crate) async fn create_test_trail(&self, data: Data) -> anyhow::Result { + self.create_test_trail_with_tags(data, std::iter::empty::()) + .await + } + + /// Creates a trail with the given initial record data and available tags. + pub(crate) async fn create_test_trail_with_tags(&self, data: Data, tags: I) -> anyhow::Result + where + I: IntoIterator, + S: Into, + { let created = self .create_trail() .with_initial_record(data, None) + .with_available_record_tags(tags) .finish() .build_and_execute(self) .await? @@ -102,14 +113,18 @@ impl TestClient { trail_id: ObjectID, role_name: &str, permissions: impl IntoIterator, + record_tags: Option, ) -> anyhow::Result { let created = self .trail(trail_id) .roles() .for_role(role_name) - .create(PermissionSet { - permissions: permissions.into_iter().collect::>(), - }) + .create( + PermissionSet { + permissions: permissions.into_iter().collect::>(), + }, + record_tags, + ) .build_and_execute(self) .await? .output; diff --git a/audit-trail-rs/tests/e2e/locking.rs b/audit-trail-rs/tests/e2e/locking.rs index 20112177..c1cbdcc0 100644 --- a/audit-trail-rs/tests/e2e/locking.rs +++ b/audit-trail-rs/tests/e2e/locking.rs @@ -12,7 +12,7 @@ async fn grant_role_capability( role_name: &str, permissions: impl IntoIterator, ) -> anyhow::Result<()> { - client.create_role(trail_id, role_name, permissions).await?; + client.create_role(trail_id, role_name, permissions, None).await?; client .issue_cap(trail_id, role_name, CapabilityIssueOptions::default()) .await?; @@ -187,7 +187,7 @@ async fn update_write_lock_roundtrip_and_blocks_add_record() -> anyhow::Result<( let add_locked = trail .records() - .add(Data::text("should-fail-write-locked"), None) + .add(Data::text("should-fail-write-locked"), None, None) .build_and_execute(&client) .await; assert!(add_locked.is_err(), "write lock should block adding new records"); @@ -257,12 +257,12 @@ async fn is_record_locked_supports_count_window_and_missing_sequence() -> anyhow trail .records() - .add(Data::text("record-1"), None) + .add(Data::text("record-1"), None, None) .build_and_execute(&client) .await?; trail .records() - .add(Data::text("record-2"), None) + .add(Data::text("record-2"), None, None) .build_and_execute(&client) .await?; @@ -355,7 +355,7 @@ async fn updated_time_lock_blocks_record_deletion() -> anyhow::Result<()> { trail .records() - .add("deletable-before-lock".into(), None) + .add("deletable-before-lock".into(), None, None) .build_and_execute(&client) .await?; diff --git a/audit-trail-rs/tests/e2e/main.rs b/audit-trail-rs/tests/e2e/main.rs index 7c076225..2f78832e 100644 --- a/audit-trail-rs/tests/e2e/main.rs +++ b/audit-trail-rs/tests/e2e/main.rs @@ -3,8 +3,8 @@ // Rust tests for Audit Trails have been temporarily deactivated during development. // Uncomment the following modules to re-enable them. -// mod client; -// mod locking; -// mod records; -// mod roles; -// mod trail; +mod client; +mod locking; +mod records; +mod roles; +mod trail; diff --git a/audit-trail-rs/tests/e2e/records.rs b/audit-trail-rs/tests/e2e/records.rs index 29be4dfe..3fd52fb1 100644 --- a/audit-trail-rs/tests/e2e/records.rs +++ b/audit-trail-rs/tests/e2e/records.rs @@ -1,7 +1,9 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use audit_trails::core::types::{CapabilityIssueOptions, Data, LockingConfig, LockingWindow, Permission, TimeLock}; +use audit_trails::core::types::{ + CapabilityIssueOptions, Data, LockingConfig, LockingWindow, Permission, RecordTags, TimeLock, +}; use audit_trails::error::Error; use iota_interaction::types::base_types::ObjectID; use product_common::core_client::CoreClient; @@ -14,7 +16,7 @@ async fn grant_role_capability( role_name: &str, permissions: impl IntoIterator, ) -> anyhow::Result<()> { - client.create_role(trail_id, role_name, permissions).await?; + client.create_role(trail_id, role_name, permissions, None).await?; client .issue_cap(trail_id, role_name, CapabilityIssueOptions::default()) .await?; @@ -45,7 +47,7 @@ async fn add_and_fetch_record_roundtrip() -> anyhow::Result<()> { grant_role_capability(&client, trail_id, "RecordWriter", [Permission::AddRecord]).await?; let added = records - .add(Data::text("second record"), Some("second metadata".to_string())) + .add(Data::text("second record"), Some("second metadata".to_string()), None) .build_and_execute(&client) .await? .output; @@ -67,6 +69,102 @@ async fn add_and_fetch_record_roundtrip() -> anyhow::Result<()> { Ok(()) } +#[tokio::test] +async fn add_and_fetch_tagged_record_roundtrip() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client + .create_test_trail_with_tags(Data::text("records-tagged"), ["finance"]) + .await?; + let records = client.trail(trail_id).records(); + + client + .create_role( + trail_id, + "TaggedWriter", + [Permission::AddRecord], + Some(RecordTags::new(["finance"])), + ) + .await?; + client + .issue_cap(trail_id, "TaggedWriter", CapabilityIssueOptions::default()) + .await?; + + let added = records + .add( + Data::text("finance record"), + Some("tagged metadata".to_string()), + Some("finance".to_string()), + ) + .build_and_execute(&client) + .await? + .output; + + assert_eq!(added.trail_id, trail_id); + assert_eq!(added.sequence_number, 1); + + let record = records.get(1).await?; + assert_eq!(record.tag, Some("finance".to_string())); + assert_eq!(record.metadata, Some("tagged metadata".to_string())); + assert_text_data(record.data, "finance record"); + + Ok(()) +} + +#[tokio::test] +async fn add_tagged_record_requires_matching_role_tag_access() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client + .create_test_trail_with_tags(Data::text("records-tagged-deny"), ["finance"]) + .await?; + let records = client.trail(trail_id).records(); + + grant_role_capability(&client, trail_id, "PlainWriter", [Permission::AddRecord]).await?; + + let denied = records + .add(Data::text("should fail"), None, Some("finance".to_string())) + .build_and_execute(&client) + .await; + + assert!(denied.is_err(), "tagged writes should require matching role tag access"); + assert_eq!(records.record_count().await?, 1); + + Ok(()) +} + +#[tokio::test] +async fn add_tagged_record_requires_trail_defined_tag() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client + .create_test_trail_with_tags(Data::text("records-tagged-undefined"), ["finance"]) + .await?; + let records = client.trail(trail_id).records(); + + client + .create_role( + trail_id, + "TaggedWriter", + [Permission::AddRecord], + Some(RecordTags::new(["finance"])), + ) + .await?; + client + .issue_cap(trail_id, "TaggedWriter", CapabilityIssueOptions::default()) + .await?; + + let denied = records + .add(Data::text("should fail"), None, Some("legal".to_string())) + .build_and_execute(&client) + .await; + + assert!( + denied.is_err(), + "tagged writes should require the tag to be defined on the trail" + ); + assert_eq!(records.record_count().await?, 1); + + Ok(()) +} + #[tokio::test] async fn add_record_rejects_mismatched_data_type() -> anyhow::Result<()> { let client = get_funded_test_client().await?; @@ -76,7 +174,11 @@ async fn add_record_rejects_mismatched_data_type() -> anyhow::Result<()> { grant_role_capability(&client, trail_id, "RecordWriter", [Permission::AddRecord]).await?; let add_mismatch = records - .add(Data::bytes(vec![0xFF, 0x00, 0xAA]), Some("binary payload".to_string())) + .add( + Data::bytes(vec![0xFF, 0x00, 0xAA]), + Some("binary payload".to_string()), + None, + ) .build_and_execute(&client) .await; @@ -116,7 +218,7 @@ async fn delete_record_removes_entry_and_keeps_sequence_monotonic() -> anyhow::R .await?; let added = records - .add(Data::text("surviving record"), Some("keep me".to_string())) + .add(Data::text("surviving record"), Some("keep me".to_string()), None) .build_and_execute(&client) .await? .output; @@ -214,7 +316,7 @@ async fn sequence_numbers_do_not_reuse_deleted_slots() -> anyhow::Result<()> { .await?; let first_added = records - .add(Data::text("first added"), None) + .add(Data::text("first added"), None, None) .build_and_execute(&client) .await? .output; @@ -223,7 +325,7 @@ async fn sequence_numbers_do_not_reuse_deleted_slots() -> anyhow::Result<()> { records.delete(1).build_and_execute(&client).await?; let second_added = records - .add(Data::text("second added"), None) + .add(Data::text("second added"), None, None) .build_and_execute(&client) .await? .output; @@ -286,11 +388,11 @@ async fn delete_records_batch_respects_limit_and_deletes_oldest_first() -> anyho .await?; records - .add(Data::text("batch-second"), None) + .add(Data::text("batch-second"), None, None) .build_and_execute(&client) .await?; records - .add(Data::text("batch-third"), None) + .add(Data::text("batch-third"), None, None) .build_and_execute(&client) .await?; @@ -339,11 +441,11 @@ async fn list_and_pagination_support_sparse_sequence_numbers() -> anyhow::Result .await?; records - .add(Data::text("second"), Some("m2".to_string())) + .add(Data::text("second"), Some("m2".to_string()), None) .build_and_execute(&client) .await?; records - .add(Data::text("third"), Some("m3".to_string())) + .add(Data::text("third"), Some("m3".to_string()), None) .build_and_execute(&client) .await?; records.delete(1).build_and_execute(&client).await?; @@ -388,7 +490,11 @@ async fn list_and_pagination_multi_page_through_roundtrip() -> anyhow::Result<() for (idx, label) in ["r1", "r2", "r3", "r4", "r5", "r6"].into_iter().enumerate() { records - .add(Data::text(format!("record-{label}")), Some(format!("meta-{}", idx + 1))) + .add( + Data::text(format!("record-{label}")), + Some(format!("meta-{}", idx + 1)), + None, + ) .build_and_execute(&client) .await?; } @@ -468,7 +574,7 @@ async fn list_page_cursor_validation_and_mid_cursor_start() -> anyhow::Result<() for label in ["r1", "r2", "r3", "r4"] { records - .add(Data::text(format!("record-{label}")), None) + .add(Data::text(format!("record-{label}")), None, None) .build_and_execute(&client) .await?; } diff --git a/audit-trail-rs/tests/e2e/roles.rs b/audit-trail-rs/tests/e2e/roles.rs index 76bf9030..3f2d9990 100644 --- a/audit-trail-rs/tests/e2e/roles.rs +++ b/audit-trail-rs/tests/e2e/roles.rs @@ -3,7 +3,7 @@ use std::collections::HashSet; -use audit_trails::core::types::{CapabilityIssueOptions, Data, Permission, PermissionSet}; +use audit_trails::core::types::{CapabilityIssueOptions, Data, Permission, PermissionSet, RecordTags}; use iota_interaction::types::base_types::IotaAddress; use product_common::core_client::CoreClient; @@ -16,7 +16,7 @@ async fn create_role_then_issue_capability_default_options() -> anyhow::Result<( let role_name = "auditor"; client - .create_role(trail_id, role_name, vec![Permission::AddRecord]) + .create_role(trail_id, role_name, vec![Permission::AddRecord], None) .await?; let issued = client @@ -40,14 +40,17 @@ async fn update_role_permissions_then_issue_capability() -> anyhow::Result<()> { let role_name = "editor"; client - .create_role(trail_id, role_name, vec![Permission::AddRecord]) + .create_role(trail_id, role_name, vec![Permission::AddRecord], None) .await?; let updated = roles .for_role(role_name) - .update_permissions(PermissionSet { - permissions: HashSet::from([Permission::AddRecord, Permission::DeleteRecord]), - }) + .update_permissions( + PermissionSet { + permissions: HashSet::from([Permission::AddRecord, Permission::DeleteRecord]), + }, + None, + ) .build_and_execute(&client) .await? .output; @@ -63,6 +66,62 @@ async fn update_role_permissions_then_issue_capability() -> anyhow::Result<()> { Ok(()) } +#[tokio::test] +async fn create_role_rejects_undefined_record_tags() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client + .create_test_trail_with_tags(Data::text("roles-undefined-create"), ["legal"]) + .await?; + + let created = client + .create_role( + trail_id, + "tagged-writer", + vec![Permission::AddRecord], + Some(RecordTags::new(["finance"])), + ) + .await; + + assert!( + created.is_err(), + "creating a role with tags outside the trail registry must fail" + ); + + Ok(()) +} + +#[tokio::test] +async fn update_role_permissions_rejects_undefined_record_tags() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client + .create_test_trail_with_tags(Data::text("roles-undefined-update"), ["legal"]) + .await?; + let roles = client.trail(trail_id).roles(); + let role_name = "editor"; + + client + .create_role(trail_id, role_name, vec![Permission::AddRecord], None) + .await?; + + let updated = roles + .for_role(role_name) + .update_permissions( + PermissionSet { + permissions: HashSet::from([Permission::AddRecord]), + }, + Some(RecordTags::new(["finance"])), + ) + .build_and_execute(&client) + .await; + + assert!( + updated.is_err(), + "updating a role with tags outside the trail registry must fail" + ); + + Ok(()) +} + #[tokio::test] async fn delete_role_prevents_new_capability_issuance() -> anyhow::Result<()> { let client = get_funded_test_client().await?; @@ -71,7 +130,7 @@ async fn delete_role_prevents_new_capability_issuance() -> anyhow::Result<()> { let role_name = "to-delete"; client - .create_role(trail_id, role_name, vec![Permission::AddRecord]) + .create_role(trail_id, role_name, vec![Permission::AddRecord], None) .await?; let deleted = roles .for_role(role_name) @@ -100,7 +159,7 @@ async fn issue_capability_with_constraints() -> anyhow::Result<()> { let role_name = "reviewer"; client - .create_role(trail_id, role_name, vec![Permission::AddRecord]) + .create_role(trail_id, role_name, vec![Permission::AddRecord], None) .await?; let issued_to = IotaAddress::random_for_testing_only(); @@ -129,7 +188,7 @@ async fn revoke_capability_emits_expected_event_data() -> anyhow::Result<()> { let role_name = "revoker"; client - .create_role(trail_id, role_name, vec![Permission::AddRecord]) + .create_role(trail_id, role_name, vec![Permission::AddRecord], None) .await?; let issued = client @@ -155,7 +214,7 @@ async fn destroy_capability_emits_expected_event_data() -> anyhow::Result<()> { let role_name = "destroyer"; client - .create_role(trail_id, role_name, vec![Permission::AddRecord]) + .create_role(trail_id, role_name, vec![Permission::AddRecord], None) .await?; let issued = client diff --git a/audit-trail-rs/tests/e2e/trail.rs b/audit-trail-rs/tests/e2e/trail.rs index d84a6448..9d29f3be 100644 --- a/audit-trail-rs/tests/e2e/trail.rs +++ b/audit-trail-rs/tests/e2e/trail.rs @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 use audit_trails::core::types::{ - CapabilityIssueOptions, Data, ImmutableMetadata, LockingConfig, LockingWindow, Permission, TimeLock, + CapabilityIssueOptions, Data, ImmutableMetadata, LockingConfig, LockingWindow, Permission, RecordTags, TimeLock, }; use iota_interaction::types::base_types::IotaAddress; use product_common::core_client::CoreClient; @@ -199,7 +199,7 @@ async fn update_metadata_roundtrip() -> anyhow::Result<()> { let trail_id = client.create_test_trail(Data::text("trail-update-meta-e2e")).await?; // Set initial updatable metadata via update_metadata client - .create_role(trail_id, "MetadataAdmin", vec![Permission::UpdateMetadata]) + .create_role(trail_id, "MetadataAdmin", vec![Permission::UpdateMetadata], None) .await?; client .issue_cap(trail_id, "MetadataAdmin", CapabilityIssueOptions::default()) @@ -233,7 +233,7 @@ async fn update_metadata_to_none_clears_value() -> anyhow::Result<()> { let trail_id = client.create_test_trail(Data::text("trail-clear-meta-e2e")).await?; client - .create_role(trail_id, "MetadataAdmin", vec![Permission::UpdateMetadata]) + .create_role(trail_id, "MetadataAdmin", vec![Permission::UpdateMetadata], None) .await?; client .issue_cap(trail_id, "MetadataAdmin", CapabilityIssueOptions::default()) @@ -260,7 +260,7 @@ async fn update_metadata_multiple_times() -> anyhow::Result<()> { let trail_id = client.create_test_trail(Data::text("trail-multi-meta-e2e")).await?; client - .create_role(trail_id, "MetadataAdmin", vec![Permission::UpdateMetadata]) + .create_role(trail_id, "MetadataAdmin", vec![Permission::UpdateMetadata], None) .await?; client .issue_cap(trail_id, "MetadataAdmin", CapabilityIssueOptions::default()) @@ -304,7 +304,7 @@ async fn update_metadata_does_not_affect_immutable_metadata() -> anyhow::Result< let trail_id = created.trail_id; client - .create_role(trail_id, "MetadataAdmin", vec![Permission::UpdateMetadata]) + .create_role(trail_id, "MetadataAdmin", vec![Permission::UpdateMetadata], None) .await?; client .issue_cap(trail_id, "MetadataAdmin", CapabilityIssueOptions::default()) @@ -331,7 +331,7 @@ async fn delete_audit_trail_fails_when_records_exist() -> anyhow::Result<()> { .create_test_trail(Data::text("trail-delete-not-empty-e2e")) .await?; client - .create_role(trail_id, "TrailDeleteOnly", vec![Permission::DeleteAuditTrail]) + .create_role(trail_id, "TrailDeleteOnly", vec![Permission::DeleteAuditTrail], None) .await?; client .issue_cap(trail_id, "TrailDeleteOnly", CapabilityIssueOptions::default()) @@ -363,6 +363,7 @@ async fn delete_records_batch_then_delete_audit_trail_roundtrip() -> anyhow::Res created.trail_id, "TrailDeleteMaintenance", vec![Permission::DeleteAllRecords, Permission::DeleteAuditTrail], + None, ) .await?; client @@ -396,3 +397,88 @@ async fn delete_records_batch_then_delete_audit_trail_roundtrip() -> anyhow::Res Ok(()) } + +#[tokio::test] +async fn manage_record_tag_registry_roundtrip() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + + let created = client + .create_trail() + .with_initial_record(Data::text("trail-tag-registry"), None) + .with_available_record_tags(["finance"]) + .finish() + .build_and_execute(&client) + .await? + .output; + + let trail = client.trail(created.trail_id); + let initial = trail.get().await?; + assert_eq!(initial.available_record_tags.contents, vec!["finance".to_string()]); + + trail.add_record_tag("legal").build_and_execute(&client).await?; + let after_add = trail.get().await?; + assert!( + after_add + .available_record_tags + .contents + .contains(&"finance".to_string()) + ); + assert!(after_add.available_record_tags.contents.contains(&"legal".to_string())); + + trail + .set_record_tags(["finance", "hr"]) + .build_and_execute(&client) + .await?; + let after_set = trail.get().await?; + assert!( + after_set + .available_record_tags + .contents + .contains(&"finance".to_string()) + ); + assert!(after_set.available_record_tags.contents.contains(&"hr".to_string())); + assert!(!after_set.available_record_tags.contents.contains(&"legal".to_string())); + + trail.remove_record_tag("hr").build_and_execute(&client).await?; + let after_remove = trail.get().await?; + assert_eq!(after_remove.available_record_tags.contents, vec!["finance".to_string()]); + + Ok(()) +} + +#[tokio::test] +async fn remove_record_tag_rejects_in_use_tag() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let created = client + .create_trail() + .with_initial_record(Data::text("trail-tag-in-use"), None) + .with_available_record_tags(["finance"]) + .finish() + .build_and_execute(&client) + .await? + .output; + + client + .create_role( + created.trail_id, + "TaggedWriter", + vec![Permission::AddRecord], + Some(RecordTags::new(["finance"])), + ) + .await?; + client + .issue_cap(created.trail_id, "TaggedWriter", CapabilityIssueOptions::default()) + .await?; + + let trail = client.trail(created.trail_id); + trail + .records() + .add(Data::text("tagged"), None, Some("finance".to_string())) + .build_and_execute(&client) + .await?; + + let removed = trail.remove_record_tag("finance").build_and_execute(&client).await; + assert!(removed.is_err(), "used record tags must not be removable"); + + Ok(()) +} From d761da10af8d65d1a1f6d727fbc5232ca21a5cb9 Mon Sep 17 00:00:00 2001 From: Yasir Date: Mon, 16 Mar 2026 13:46:44 +0300 Subject: [PATCH 073/189] Refactor record tag management in audit trail - Consolidate record tag management functions into a dedicated `tags` module. - Introduce `AddRecordTag`, `RemoveRecordTag`, and `SetRecordTags` transactions for better encapsulation. - Update permissions to include `AddRecordTags` and `DeleteRecordTags`. - Modify existing functions and tests to utilize the new structure for managing record tags. - Remove deprecated functions and ensure all references are updated accordingly. - Enhance tests to validate the new tag management functionality and permissions. --- audit-trail-move/sources/audit_trail.move | 96 +++-------- audit-trail-move/sources/permission.move | 25 +++ .../tests/create_audit_trail_tests.move | 136 +++++++++------ audit-trail-move/tests/permission_tests.move | 17 ++ audit-trail-move/tests/record_tests.move | 4 +- audit-trail-rs/src/core/mod.rs | 1 + audit-trail-rs/src/core/operations.rs | 41 +++++ audit-trail-rs/src/core/records/operations.rs | 4 +- audit-trail-rs/src/core/tags/mod.rs | 63 +++++++ audit-trail-rs/src/core/tags/operations.rs | 96 +++++++++++ audit-trail-rs/src/core/tags/transactions.rs | 155 ++++++++++++++++++ audit-trail-rs/src/core/trail.rs | 43 +---- audit-trail-rs/src/core/trail/operations.rs | 102 +----------- audit-trail-rs/src/core/trail/transactions.rs | 141 ---------------- audit-trail-rs/src/core/types/permission.rs | 12 ++ audit-trail-rs/tests/e2e/trail.rs | 11 +- 16 files changed, 534 insertions(+), 413 deletions(-) create mode 100644 audit-trail-rs/src/core/tags/mod.rs create mode 100644 audit-trail-rs/src/core/tags/operations.rs create mode 100644 audit-trail-rs/src/core/tags/transactions.rs diff --git a/audit-trail-move/sources/audit_trail.move b/audit-trail-move/sources/audit_trail.move index dd1e68d4..f5d128ce 100644 --- a/audit-trail-move/sources/audit_trail.move +++ b/audit-trail-move/sources/audit_trail.move @@ -43,18 +43,13 @@ const EPackageVersionMismatch: vector = const ERecordTagNotAllowed: vector = b"The provided capability cannot create records with the requested tag"; #[error] -const ERecordTagNotDefined: vector = - b"The requested tag is not defined for this audit trail"; +const ERecordTagNotDefined: vector = b"The requested tag is not defined for this audit trail"; #[error] const ERecordTagAlreadyDefined: vector = b"The requested tag is already defined for this audit trail"; #[error] const ERecordTagInUse: vector = b"The requested tag cannot be removed because it is already used by an existing record"; -#[error] -const ERecordTagAdminOnly: vector = - b"Only the Admin role may manage the trail record-tag registry"; - // ===== Constants ===== const INITIAL_ADMIN_ROLE_NAME: vector = b"Admin"; @@ -267,33 +262,6 @@ entry fun migrate( trail.version = PACKAGE_VERSION; } -fun assert_defined_record_tags( - self: &AuditTrail, - record_tags: &Option, -) { - assert!( - record_tags::defined_for_trail(&self.available_record_tags, record_tags), - ERecordTagNotDefined, - ); -} - -fun assert_record_tag_admin( - self: &AuditTrail, - cap: &Capability, - clock: &Clock, - ctx: &TxContext, -) { - self - .roles - .assert_capability_valid( - cap, - &permission::add_roles(), - clock, - ctx, - ); - assert!(*cap.role() == initial_admin_role_name(), ERecordTagAdminOnly); -} - fun assert_record_tag_allowed( self: &AuditTrail, cap: &Capability, @@ -304,7 +272,10 @@ fun assert_record_tag_allowed( }; let requested_tag = option::borrow(tag); - assert!(record_tags::is_defined(&self.available_record_tags, requested_tag), ERecordTagNotDefined); + assert!( + record_tags::is_defined(&self.available_record_tags, requested_tag), + ERecordTagNotDefined, + ); assert!(record_tags::role_allows(&self.roles, cap, requested_tag), ERecordTagNotAllowed); } @@ -610,7 +581,7 @@ public fun update_metadata( } /// Adds a new record tag to the trail registry. -public fun add_available_record_tag( +public fun add_record_tag( self: &mut AuditTrail, cap: &Capability, tag: String, @@ -618,13 +589,15 @@ public fun add_available_record_tag( ctx: &TxContext, ) { assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); - assert_record_tag_admin(self, cap, clock, ctx); + + self.roles.assert_capability_valid(cap, &permission::add_record_tags(), clock, ctx); + assert!(!iota::vec_set::contains(&self.available_record_tags, &tag), ERecordTagAlreadyDefined); self.available_record_tags.insert(tag); } /// Removes a record tag from the trail registry if it is not used by any record. -public fun remove_available_record_tag( +public fun remove_record_tag( self: &mut AuditTrail, cap: &Capability, tag: String, @@ -632,42 +605,14 @@ public fun remove_available_record_tag( ctx: &TxContext, ) { assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); - assert_record_tag_admin(self, cap, clock, ctx); + + self.roles.assert_capability_valid(cap, &permission::delete_record_tags(), clock, ctx); + assert!(iota::vec_set::contains(&self.available_record_tags, &tag), ERecordTagNotDefined); assert!(!record_tags::is_in_use(&self.records, self.sequence_number, &tag), ERecordTagInUse); self.available_record_tags.remove(&tag); } -/// Replaces the trail registry with a new set of available record tags. -public fun set_available_record_tags( - self: &mut AuditTrail, - cap: &Capability, - tags: vector, - clock: &Clock, - ctx: &TxContext, -) { - assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); - assert_record_tag_admin(self, cap, clock, ctx); - - let new_tags = iota::vec_set::from_keys(tags); - let existing_tags = iota::vec_set::keys(&self.available_record_tags); - let mut i = 0; - let existing_tag_count = existing_tags.length(); - - while (i < existing_tag_count) { - let existing_tag = &existing_tags[i]; - if (!iota::vec_set::contains(&new_tags, existing_tag)) { - assert!( - !record_tags::is_in_use(&self.records, self.sequence_number, existing_tag), - ERecordTagInUse, - ); - }; - i = i + 1; - }; - - self.available_record_tags = new_tags; -} - // ===== Role and Capability Administration ===== /// Creates a new role with the provided permissions. @@ -681,7 +626,12 @@ public fun create_role( ctx: &mut TxContext, ) { assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); - assert_defined_record_tags(self, &record_tags); + + assert!( + record_tags::defined_for_trail(&self.available_record_tags, &record_tags), + ERecordTagNotDefined, + ); + role_map::create_role( self.roles_mut(), cap, @@ -704,7 +654,11 @@ public fun update_role_permissions( ctx: &mut TxContext, ) { assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); - assert_defined_record_tags(self, &record_tags); + + assert!( + record_tags::defined_for_trail(&self.available_record_tags, &record_tags), + ERecordTagNotDefined, + ); role_map::update_role( self.roles_mut(), cap, @@ -929,7 +883,7 @@ public fun roles(self: &AuditTrail): &RoleMap( +public(package) fun roles_mut( self: &mut AuditTrail, ): &mut RoleMap { assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); diff --git a/audit-trail-move/sources/permission.move b/audit-trail-move/sources/permission.move index aeb8f03d..b16a984b 100644 --- a/audit-trail-move/sources/permission.move +++ b/audit-trail-move/sources/permission.move @@ -48,6 +48,11 @@ public enum Permission has copy, drop, store { DeleteMetadata, /// Migrate the audit trail to a new version of the contract Migrate, + // --- Record Tag Management - Proposed role: `TagAdmin` --- + /// Add new record tags to the trail registry + AddRecordTags, + /// Remove record tags from the trail registry + DeleteRecordTags, } /// Create an empty permission set @@ -84,6 +89,8 @@ public fun admin_permissions(): VecSet { let mut perms = vec_set::empty(); perms.insert(add_capabilities()); perms.insert(revoke_capabilities()); + perms.insert(add_record_tags()); + perms.insert(delete_record_tags()); perms.insert(add_roles()); perms.insert(update_roles()); perms.insert(delete_roles()); @@ -118,6 +125,14 @@ public fun role_admin_permissions(): VecSet { perms } +/// Create permissions typically used for the `TagAdmin` role +public fun tag_admin_permissions(): VecSet { + let mut perms = vec_set::empty(); + perms.insert(add_record_tags()); + perms.insert(delete_record_tags()); + perms +} + /// Create permissions typical used for the `CapAdmin` role public fun cap_admin_permissions(): VecSet { let mut perms = vec_set::empty(); @@ -181,6 +196,16 @@ public fun update_locking_config_for_write(): Permission { Permission::UpdateLockingConfigForWrite } +/// Returns a permission allowing to add new record tags to the trail registry +public fun add_record_tags(): Permission { + Permission::AddRecordTags +} + +/// Returns a permission allowing to remove record tags from the trail registry +public fun delete_record_tags(): Permission { + Permission::DeleteRecordTags +} + /// Returns a permission allowing to add new roles with associated permissions public fun add_roles(): Permission { Permission::AddRoles diff --git a/audit-trail-move/tests/create_audit_trail_tests.move b/audit-trail-move/tests/create_audit_trail_tests.move index 283ed889..7610e5b3 100644 --- a/audit-trail-move/tests/create_audit_trail_tests.move +++ b/audit-trail-move/tests/create_audit_trail_tests.move @@ -5,13 +5,15 @@ module audit_trail::create_audit_trail_tests; use audit_trail::{ locking, main::{Self, AuditTrail, initial_admin_role_name}, + permission, test_utils::{ setup_test_audit_trail, new_test_data, initial_time_for_testing, TestData, fetch_capability_trail_and_clock, - cleanup_capability_trail_and_clock + cleanup_capability_trail_and_clock, + new_capability_for_address } }; use iota::{clock, test_scenario as ts, vec_set}; @@ -59,6 +61,86 @@ fun test_create_without_initial_record() { ts::end(scenario); } +#[test] +fun test_tag_admin_role_can_manage_available_record_tags() { + let admin = @0xA; + let tag_admin = @0xB; + let mut scenario = ts::begin(admin); + + { + let locking_config = locking::new( + locking::window_count_based(0), + timelock::none(), + timelock::none(), + ); + + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + option::none(), + ); + + transfer::public_transfer(admin_cap, admin); + }; + + ts::next_tx(&mut scenario, admin); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + trail.create_role( + &admin_cap, + string::utf8(b"TagAdmin"), + permission::tag_admin_permissions(), + option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + let tag_admin_cap = new_capability_for_address( + trail.roles_mut(), + &admin_cap, + &string::utf8(b"TagAdmin"), + tag_admin, + option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(tag_admin_cap, tag_admin); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + }; + + ts::next_tx(&mut scenario, tag_admin); + { + let (tag_admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + trail.add_record_tag( + &tag_admin_cap, + string::utf8(b"finance"), + &clock, + ts::ctx(&mut scenario), + ); + + let available_tags = trail.available_record_tags(); + assert!(vec_set::size(available_tags) == 1, 0); + assert!(vec_set::contains(available_tags, &string::utf8(b"finance")), 1); + + trail.remove_record_tag( + &tag_admin_cap, + string::utf8(b"finance"), + &clock, + ts::ctx(&mut scenario), + ); + + let available_tags = trail.available_record_tags(); + assert!(vec_set::size(available_tags) == 0, 0); + + cleanup_capability_trail_and_clock(&scenario, tag_admin_cap, trail, clock); + }; + + ts::end(scenario); +} + #[test] fun test_create_with_initial_record() { let user = @0xB; @@ -311,55 +393,3 @@ fun test_create_metadata_admin_role() { ts::end(scenario); } - -#[test] -fun test_manage_available_record_tags_roundtrip() { - let admin = @0xF; - let mut scenario = ts::begin(admin); - - { - let locking_config = locking::new( - locking::window_count_based(0), - timelock::none(), - timelock::none(), - ); - let (admin_cap, _) = setup_test_audit_trail( - &mut scenario, - locking_config, - option::none(), - ); - transfer::public_transfer(admin_cap, admin); - }; - - ts::next_tx(&mut scenario, admin); - { - let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); - - trail.add_available_record_tag( - &admin_cap, - string::utf8(b"finance"), - &clock, - ts::ctx(&mut scenario), - ); - trail.set_available_record_tags( - &admin_cap, - vector[string::utf8(b"finance"), string::utf8(b"legal")], - &clock, - ts::ctx(&mut scenario), - ); - trail.remove_available_record_tag( - &admin_cap, - string::utf8(b"legal"), - &clock, - ts::ctx(&mut scenario), - ); - - let available_tags = trail.available_record_tags(); - assert!(vec_set::size(available_tags) == 1, 0); - assert!(vec_set::contains(available_tags, &string::utf8(b"finance")), 1); - - cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); - }; - - ts::end(scenario); -} diff --git a/audit-trail-move/tests/permission_tests.move b/audit-trail-move/tests/permission_tests.move index 847467ce..67ae90c6 100644 --- a/audit-trail-move/tests/permission_tests.move +++ b/audit-trail-move/tests/permission_tests.move @@ -99,6 +99,23 @@ fun test_metadata_admin_permissions() { assert!(iota::vec_set::size(&perms) == 2, 0); } +#[test] +fun test_tag_admin_permissions() { + let perms = permission::tag_admin_permissions(); + + assert!(permission::has_permission(&perms, &permission::add_record_tags()), 0); + assert!(permission::has_permission(&perms, &permission::delete_record_tags()), 1); + assert!(iota::vec_set::size(&perms) == 2, 2); +} + +#[test] +fun test_admin_permissions_include_tag_management() { + let perms = permission::admin_permissions(); + + assert!(permission::has_permission(&perms, &permission::add_record_tags()), 0); + assert!(permission::has_permission(&perms, &permission::delete_record_tags()), 1); +} + #[test] #[expected_failure(abort_code = vec_set::EKeyAlreadyExists)] fun test_from_vec_duplicate_permission() { diff --git a/audit-trail-move/tests/record_tests.move b/audit-trail-move/tests/record_tests.move index 25acb386..1bc36bef 100644 --- a/audit-trail-move/tests/record_tests.move +++ b/audit-trail-move/tests/record_tests.move @@ -315,7 +315,7 @@ fun test_add_tagged_record_requires_trail_defined_tag() { #[test] #[expected_failure(abort_code = audit_trail::main::ERecordTagInUse)] -fun test_remove_available_record_tag_rejects_in_use_tag() { +fun test_remove_record_tag_rejects_in_use_tag() { let admin = @0xAD; let writer = @0xB0B; let mut scenario = ts::begin(admin); @@ -384,7 +384,7 @@ fun test_remove_available_record_tag_rejects_in_use_tag() { { let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); - trail.remove_available_record_tag( + trail.remove_record_tag( &admin_cap, string::utf8(b"finance"), &clock, diff --git a/audit-trail-rs/src/core/mod.rs b/audit-trail-rs/src/core/mod.rs index 61bb2078..ab06ac41 100644 --- a/audit-trail-rs/src/core/mod.rs +++ b/audit-trail-rs/src/core/mod.rs @@ -9,6 +9,7 @@ pub mod locking; pub(crate) mod operations; pub mod records; pub mod roles; +pub mod tags; pub mod trail; pub mod types; pub(crate) mod utils; diff --git a/audit-trail-rs/src/core/operations.rs b/audit-trail-rs/src/core/operations.rs index 20fa30c0..a5c53353 100644 --- a/audit-trail-rs/src/core/operations.rs +++ b/audit-trail-rs/src/core/operations.rs @@ -94,6 +94,47 @@ where utils::get_object_ref_by_id(client, &object_id).await } +/// Finds a capability owned by `owner` whose role has all required permissions +/// according to the trail's RoleMap. +pub(crate) async fn find_capable_cap_with_permissions( + client: &C, + owner: IotaAddress, + trail_id: ObjectID, + trail: &OnChainAuditTrail, + required_permissions: &[Permission], +) -> Result +where + C: CoreClientReadOnly + OptionalSync, +{ + let valid_roles: HashSet<&String> = trail + .roles + .roles + .iter() + .filter(|(_, role)| { + required_permissions + .iter() + .all(|permission| role.permissions.contains(permission)) + }) + .map(|(name, _)| name) + .collect(); + + let cap: Capability = client + .find_object_for_address(owner, |cap: &Capability| { + cap.target_key == trail_id && valid_roles.contains(&cap.role) + }) + .await + .map_err(|e| Error::RpcError(e.to_string()))? + .ok_or_else(|| { + Error::InvalidArgument(format!( + "no capability with {:?} permissions found for owner {owner} and trail {trail_id}", + required_permissions + )) + })?; + + let object_id = *cap.id.object_id(); + utils::get_object_ref_by_id(client, &object_id).await +} + pub(crate) async fn build_trail_transaction_with_cap_ref( client: &C, trail_id: ObjectID, diff --git a/audit-trail-rs/src/core/records/operations.rs b/audit-trail-rs/src/core/records/operations.rs index c019943a..efbab9e7 100644 --- a/audit-trail-rs/src/core/records/operations.rs +++ b/audit-trail-rs/src/core/records/operations.rs @@ -6,7 +6,7 @@ use iota_interaction::types::base_types::{IotaAddress, ObjectID}; use iota_interaction::types::transaction::ProgrammableTransaction; use product_common::core_client::CoreClientReadOnly; -use crate::core::types::{Data, Permission}; +use crate::core::types::{Data, OnChainAuditTrail, Permission}; use crate::core::{operations, utils}; use crate::error::Error; @@ -150,7 +150,7 @@ async fn find_capable_cap_for_tag( client: &C, owner: IotaAddress, trail_id: ObjectID, - trail: &crate::core::types::OnChainAuditTrail, + trail: &OnChainAuditTrail, tag: &str, ) -> Result where diff --git a/audit-trail-rs/src/core/tags/mod.rs b/audit-trail-rs/src/core/tags/mod.rs new file mode 100644 index 00000000..1a06cfd6 --- /dev/null +++ b/audit-trail-rs/src/core/tags/mod.rs @@ -0,0 +1,63 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use iota_interaction::types::base_types::ObjectID; +use iota_interaction::{IotaKeySignature, OptionalSync}; +use product_common::core_client::CoreClient; +use product_common::transaction::transaction_builder::TransactionBuilder; +use secret_storage::Signer; + +use crate::core::trail::AuditTrailFull; + +mod operations; +mod transactions; + +pub use transactions::{AddRecordTag, RemoveRecordTag, SetRecordTags}; + +#[derive(Debug, Clone)] +pub struct TrailTags<'a, C> { + pub(crate) client: &'a C, + pub(crate) trail_id: ObjectID, +} + +impl<'a, C> TrailTags<'a, C> { + pub(crate) fn new(client: &'a C, trail_id: ObjectID) -> Self { + Self { client, trail_id } + } + + /// Adds a tag to the trail-owned record-tag registry. + pub fn add(&self, tag: impl Into) -> TransactionBuilder + where + C: AuditTrailFull + CoreClient, + S: Signer + OptionalSync, + { + let owner = self.client.sender_address(); + TransactionBuilder::new(AddRecordTag::new(self.trail_id, owner, tag.into())) + } + + /// Removes a tag from the trail-owned record-tag registry. + pub fn remove(&self, tag: impl Into) -> TransactionBuilder + where + C: AuditTrailFull + CoreClient, + S: Signer + OptionalSync, + { + let owner = self.client.sender_address(); + TransactionBuilder::new(RemoveRecordTag::new(self.trail_id, owner, tag.into())) + } + + /// Replaces the entire trail-owned record-tag registry. + pub fn set(&self, tags: I) -> TransactionBuilder + where + C: AuditTrailFull + CoreClient, + S: Signer + OptionalSync, + I: IntoIterator, + T: Into, + { + let owner = self.client.sender_address(); + TransactionBuilder::new(SetRecordTags::new( + self.trail_id, + owner, + tags.into_iter().map(Into::into).collect(), + )) + } +} diff --git a/audit-trail-rs/src/core/tags/operations.rs b/audit-trail-rs/src/core/tags/operations.rs new file mode 100644 index 00000000..c7b4782c --- /dev/null +++ b/audit-trail-rs/src/core/tags/operations.rs @@ -0,0 +1,96 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use iota_interaction::OptionalSync; +use iota_interaction::types::base_types::{IotaAddress, ObjectID}; +use iota_interaction::types::transaction::ProgrammableTransaction; +use product_common::core_client::CoreClientReadOnly; + +use crate::core::types::Permission; +use crate::core::{operations, utils}; +use crate::error::Error; + +pub(super) struct TagsOps; + +impl TagsOps { + pub(super) async fn add_record_tag( + client: &C, + trail_id: ObjectID, + owner: IotaAddress, + tag: String, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + operations::build_trail_transaction( + client, + trail_id, + owner, + Permission::AddRecordTags, + "add_available_record_tag", + |ptb, _| { + let tag_arg = utils::ptb_pure(ptb, "tag", tag)?; + let clock = utils::get_clock_ref(ptb); + Ok(vec![tag_arg, clock]) + }, + ) + .await + } + + pub(super) async fn remove_record_tag( + client: &C, + trail_id: ObjectID, + owner: IotaAddress, + tag: String, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + operations::build_trail_transaction( + client, + trail_id, + owner, + Permission::DeleteRecordTags, + "remove_available_record_tag", + |ptb, _| { + let tag_arg = utils::ptb_pure(ptb, "tag", tag)?; + let clock = utils::get_clock_ref(ptb); + Ok(vec![tag_arg, clock]) + }, + ) + .await + } + + pub(super) async fn set_record_tags( + client: &C, + trail_id: ObjectID, + owner: IotaAddress, + tags: Vec, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + let trail = operations::get_audit_trail(trail_id, client).await?; + let cap_ref = operations::find_capable_cap_with_permissions( + client, + owner, + trail_id, + &trail, + &[Permission::AddRecordTags, Permission::DeleteRecordTags], + ) + .await?; + + operations::build_trail_transaction_with_cap_ref( + client, + trail_id, + cap_ref, + "set_available_record_tags", + |ptb, _| { + let tags_arg = utils::ptb_pure(ptb, "tags", tags)?; + let clock = utils::get_clock_ref(ptb); + Ok(vec![tags_arg, clock]) + }, + ) + .await + } +} diff --git a/audit-trail-rs/src/core/tags/transactions.rs b/audit-trail-rs/src/core/tags/transactions.rs new file mode 100644 index 00000000..af4c5aa2 --- /dev/null +++ b/audit-trail-rs/src/core/tags/transactions.rs @@ -0,0 +1,155 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use async_trait::async_trait; +use iota_interaction::OptionalSync; +use iota_interaction::rpc_types::IotaTransactionBlockEffects; +use iota_interaction::types::base_types::{IotaAddress, ObjectID}; +use iota_interaction::types::transaction::ProgrammableTransaction; +use product_common::core_client::CoreClientReadOnly; +use product_common::transaction::transaction_builder::Transaction; +use tokio::sync::OnceCell; + +use super::operations::TagsOps; +use crate::error::Error; + +#[derive(Debug, Clone)] +pub struct AddRecordTag { + trail_id: ObjectID, + owner: IotaAddress, + tag: String, + cached_ptb: OnceCell, +} + +impl AddRecordTag { + pub fn new(trail_id: ObjectID, owner: IotaAddress, tag: String) -> Self { + Self { + trail_id, + owner, + tag, + cached_ptb: OnceCell::new(), + } + } + + async fn make_ptb(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + TagsOps::add_record_tag(client, self.trail_id, self.owner, self.tag.clone()).await + } +} + +#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] +#[cfg_attr(feature = "send-sync", async_trait)] +impl Transaction for AddRecordTag { + type Error = Error; + type Output = (); + + async fn build_programmable_transaction(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() + } + + async fn apply(self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + Ok(()) + } +} + +#[derive(Debug, Clone)] +pub struct RemoveRecordTag { + trail_id: ObjectID, + owner: IotaAddress, + tag: String, + cached_ptb: OnceCell, +} + +impl RemoveRecordTag { + pub fn new(trail_id: ObjectID, owner: IotaAddress, tag: String) -> Self { + Self { + trail_id, + owner, + tag, + cached_ptb: OnceCell::new(), + } + } + + async fn make_ptb(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + TagsOps::remove_record_tag(client, self.trail_id, self.owner, self.tag.clone()).await + } +} + +#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] +#[cfg_attr(feature = "send-sync", async_trait)] +impl Transaction for RemoveRecordTag { + type Error = Error; + type Output = (); + + async fn build_programmable_transaction(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() + } + + async fn apply(self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + Ok(()) + } +} + +#[derive(Debug, Clone)] +pub struct SetRecordTags { + trail_id: ObjectID, + owner: IotaAddress, + tags: Vec, + cached_ptb: OnceCell, +} + +impl SetRecordTags { + pub fn new(trail_id: ObjectID, owner: IotaAddress, tags: Vec) -> Self { + Self { + trail_id, + owner, + tags, + cached_ptb: OnceCell::new(), + } + } + + async fn make_ptb(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + TagsOps::set_record_tags(client, self.trail_id, self.owner, self.tags.clone()).await + } +} + +#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] +#[cfg_attr(feature = "send-sync", async_trait)] +impl Transaction for SetRecordTags { + type Error = Error; + type Output = (); + + async fn build_programmable_transaction(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() + } + + async fn apply(self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + Ok(()) + } +} diff --git a/audit-trail-rs/src/core/trail.rs b/audit-trail-rs/src/core/trail.rs index e4e70398..2bb839ba 100644 --- a/audit-trail-rs/src/core/trail.rs +++ b/audit-trail-rs/src/core/trail.rs @@ -12,13 +12,14 @@ use serde::de::DeserializeOwned; use crate::core::locking::TrailLocking; use crate::core::records::TrailRecords; use crate::core::roles::TrailRoles; +use crate::core::tags::TrailTags; use crate::core::types::{Data, OnChainAuditTrail}; use crate::error::Error; mod operations; mod transactions; -pub use transactions::{AddRecordTag, DeleteAuditTrail, Migrate, RemoveRecordTag, SetRecordTags, UpdateMetadata}; +pub use transactions::{DeleteAuditTrail, Migrate, UpdateMetadata}; /// Marker trait for read-only audit trail clients. #[doc(hidden)] @@ -84,42 +85,6 @@ impl<'a, C> AuditTrailHandle<'a, C> { TransactionBuilder::new(DeleteAuditTrail::new(self.trail_id, owner)) } - /// Adds a tag to the trail-owned record-tag registry. - pub fn add_record_tag(&self, tag: impl Into) -> TransactionBuilder - where - C: AuditTrailFull + CoreClient, - S: Signer + OptionalSync, - { - let owner = self.client.sender_address(); - TransactionBuilder::new(AddRecordTag::new(self.trail_id, owner, tag.into())) - } - - /// Removes a tag from the trail-owned record-tag registry. - pub fn remove_record_tag(&self, tag: impl Into) -> TransactionBuilder - where - C: AuditTrailFull + CoreClient, - S: Signer + OptionalSync, - { - let owner = self.client.sender_address(); - TransactionBuilder::new(RemoveRecordTag::new(self.trail_id, owner, tag.into())) - } - - /// Replaces the entire trail-owned record-tag registry. - pub fn set_record_tags(&self, tags: I) -> TransactionBuilder - where - C: AuditTrailFull + CoreClient, - S: Signer + OptionalSync, - I: IntoIterator, - T: Into, - { - let owner = self.client.sender_address(); - TransactionBuilder::new(SetRecordTags::new( - self.trail_id, - owner, - tags.into_iter().map(Into::into).collect(), - )) - } - pub fn records(&self) -> TrailRecords<'a, C, Data> { TrailRecords::new(self.client, self.trail_id) } @@ -131,4 +96,8 @@ impl<'a, C> AuditTrailHandle<'a, C> { pub fn roles(&self) -> TrailRoles<'a, C> { TrailRoles::new(self.client, self.trail_id) } + + pub fn tags(&self) -> TrailTags<'a, C> { + TrailTags::new(self.client, self.trail_id) + } } diff --git a/audit-trail-rs/src/core/trail/operations.rs b/audit-trail-rs/src/core/trail/operations.rs index 98355b85..d5e9bca0 100644 --- a/audit-trail-rs/src/core/trail/operations.rs +++ b/audit-trail-rs/src/core/trail/operations.rs @@ -2,11 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 use iota_interaction::OptionalSync; -use iota_interaction::types::base_types::{IotaAddress, ObjectID, ObjectRef}; +use iota_interaction::types::base_types::{IotaAddress, ObjectID}; use iota_interaction::types::transaction::ProgrammableTransaction; use product_common::core_client::CoreClientReadOnly; -use crate::core::types::{Capability, Permission}; +use crate::core::types::Permission; use crate::core::{operations, utils}; use crate::error::Error; @@ -73,102 +73,4 @@ impl TrailOps { ) .await } - - pub(super) async fn add_record_tag( - client: &C, - trail_id: ObjectID, - owner: IotaAddress, - tag: String, - ) -> Result - where - C: CoreClientReadOnly + OptionalSync, - { - build_admin_trail_transaction(client, trail_id, owner, "add_available_record_tag", |ptb| { - let tag_arg = utils::ptb_pure(ptb, "tag", tag)?; - let clock = utils::get_clock_ref(ptb); - Ok(vec![tag_arg, clock]) - }) - .await - } - - pub(super) async fn remove_record_tag( - client: &C, - trail_id: ObjectID, - owner: IotaAddress, - tag: String, - ) -> Result - where - C: CoreClientReadOnly + OptionalSync, - { - build_admin_trail_transaction(client, trail_id, owner, "remove_available_record_tag", |ptb| { - let tag_arg = utils::ptb_pure(ptb, "tag", tag)?; - let clock = utils::get_clock_ref(ptb); - Ok(vec![tag_arg, clock]) - }) - .await - } - - pub(super) async fn set_record_tags( - client: &C, - trail_id: ObjectID, - owner: IotaAddress, - tags: Vec, - ) -> Result - where - C: CoreClientReadOnly + OptionalSync, - { - build_admin_trail_transaction(client, trail_id, owner, "set_available_record_tags", |ptb| { - let tags_arg = utils::ptb_pure(ptb, "tags", tags)?; - let clock = utils::get_clock_ref(ptb); - Ok(vec![tags_arg, clock]) - }) - .await - } -} - -async fn build_admin_trail_transaction( - client: &C, - trail_id: ObjectID, - owner: IotaAddress, - method: impl AsRef, - additional_args: F, -) -> Result -where - F: FnOnce( - &mut iota_interaction::types::programmable_transaction_builder::ProgrammableTransactionBuilder, - ) -> Result, Error>, - C: CoreClientReadOnly + OptionalSync, -{ - let trail = operations::get_audit_trail(trail_id, client).await?; - let admin_cap_ref = find_admin_cap(client, owner, trail_id, &trail.roles.initial_admin_role_name).await?; - - operations::build_trail_transaction_with_cap_ref(client, trail_id, admin_cap_ref, method, |ptb, _| { - additional_args(ptb) - }) - .await -} - -async fn find_admin_cap( - client: &C, - owner: IotaAddress, - trail_id: ObjectID, - admin_role_name: &str, -) -> Result -where - C: CoreClientReadOnly + OptionalSync, -{ - let cap: Capability = client - .find_object_for_address(owner, |cap: &Capability| { - cap.target_key == trail_id && cap.role == admin_role_name - }) - .await - .map_err(|e| Error::RpcError(e.to_string()))? - .ok_or_else(|| { - Error::InvalidArgument(format!( - "no Admin capability found for owner {owner} and trail {trail_id}" - )) - })?; - - let object_id = *cap.id.object_id(); - utils::get_object_ref_by_id(client, &object_id).await } diff --git a/audit-trail-rs/src/core/trail/transactions.rs b/audit-trail-rs/src/core/trail/transactions.rs index 2b55c6d5..4f12359c 100644 --- a/audit-trail-rs/src/core/trail/transactions.rs +++ b/audit-trail-rs/src/core/trail/transactions.rs @@ -106,147 +106,6 @@ impl Transaction for UpdateMetadata { } } -#[derive(Debug, Clone)] -pub struct AddRecordTag { - trail_id: ObjectID, - owner: IotaAddress, - tag: String, - cached_ptb: OnceCell, -} - -impl AddRecordTag { - pub fn new(trail_id: ObjectID, owner: IotaAddress, tag: String) -> Self { - Self { - trail_id, - owner, - tag, - cached_ptb: OnceCell::new(), - } - } - - async fn make_ptb(&self, client: &C) -> Result - where - C: CoreClientReadOnly + OptionalSync, - { - TrailOps::add_record_tag(client, self.trail_id, self.owner, self.tag.clone()).await - } -} - -#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] -#[cfg_attr(feature = "send-sync", async_trait)] -impl Transaction for AddRecordTag { - type Error = Error; - type Output = (); - - async fn build_programmable_transaction(&self, client: &C) -> Result - where - C: CoreClientReadOnly + OptionalSync, - { - self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() - } - - async fn apply(self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result - where - C: CoreClientReadOnly + OptionalSync, - { - Ok(()) - } -} - -#[derive(Debug, Clone)] -pub struct RemoveRecordTag { - trail_id: ObjectID, - owner: IotaAddress, - tag: String, - cached_ptb: OnceCell, -} - -impl RemoveRecordTag { - pub fn new(trail_id: ObjectID, owner: IotaAddress, tag: String) -> Self { - Self { - trail_id, - owner, - tag, - cached_ptb: OnceCell::new(), - } - } - - async fn make_ptb(&self, client: &C) -> Result - where - C: CoreClientReadOnly + OptionalSync, - { - TrailOps::remove_record_tag(client, self.trail_id, self.owner, self.tag.clone()).await - } -} - -#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] -#[cfg_attr(feature = "send-sync", async_trait)] -impl Transaction for RemoveRecordTag { - type Error = Error; - type Output = (); - - async fn build_programmable_transaction(&self, client: &C) -> Result - where - C: CoreClientReadOnly + OptionalSync, - { - self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() - } - - async fn apply(self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result - where - C: CoreClientReadOnly + OptionalSync, - { - Ok(()) - } -} - -#[derive(Debug, Clone)] -pub struct SetRecordTags { - trail_id: ObjectID, - owner: IotaAddress, - tags: Vec, - cached_ptb: OnceCell, -} - -impl SetRecordTags { - pub fn new(trail_id: ObjectID, owner: IotaAddress, tags: Vec) -> Self { - Self { - trail_id, - owner, - tags, - cached_ptb: OnceCell::new(), - } - } - - async fn make_ptb(&self, client: &C) -> Result - where - C: CoreClientReadOnly + OptionalSync, - { - TrailOps::set_record_tags(client, self.trail_id, self.owner, self.tags.clone()).await - } -} - -#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] -#[cfg_attr(feature = "send-sync", async_trait)] -impl Transaction for SetRecordTags { - type Error = Error; - type Output = (); - - async fn build_programmable_transaction(&self, client: &C) -> Result - where - C: CoreClientReadOnly + OptionalSync, - { - self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() - } - - async fn apply(self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result - where - C: CoreClientReadOnly + OptionalSync, - { - Ok(()) - } -} - #[derive(Debug, Clone)] pub struct DeleteAuditTrail { trail_id: ObjectID, diff --git a/audit-trail-rs/src/core/types/permission.rs b/audit-trail-rs/src/core/types/permission.rs index a07624d2..55b046cd 100644 --- a/audit-trail-rs/src/core/types/permission.rs +++ b/audit-trail-rs/src/core/types/permission.rs @@ -33,6 +33,8 @@ pub enum Permission { UpdateMetadata, DeleteMetadata, Migrate, + AddRecordTags, + DeleteRecordTags, } impl Permission { @@ -48,6 +50,8 @@ impl Permission { Self::UpdateLockingConfigForDeleteRecord => "update_locking_config_for_delete_record", Self::UpdateLockingConfigForDeleteTrail => "update_locking_config_for_delete_trail", Self::UpdateLockingConfigForWrite => "update_locking_config_for_write", + Self::AddRecordTags => "add_record_tags", + Self::DeleteRecordTags => "delete_record_tags", Self::AddRoles => "add_roles", Self::UpdateRoles => "update_roles", Self::DeleteRoles => "delete_roles", @@ -93,6 +97,8 @@ impl PermissionSet { permissions: HashSet::from([ Permission::AddCapabilities, Permission::RevokeCapabilities, + Permission::AddRecordTags, + Permission::DeleteRecordTags, Permission::AddRoles, Permission::UpdateRoles, Permission::DeleteRoles, @@ -127,6 +133,12 @@ impl PermissionSet { } } + pub fn tag_admin_permissions() -> Self { + Self { + permissions: HashSet::from([Permission::AddRecordTags, Permission::DeleteRecordTags]), + } + } + pub fn cap_admin_permissions() -> Self { Self { permissions: HashSet::from_iter(vec![Permission::AddCapabilities, Permission::RevokeCapabilities]), diff --git a/audit-trail-rs/tests/e2e/trail.rs b/audit-trail-rs/tests/e2e/trail.rs index 9d29f3be..15f3167a 100644 --- a/audit-trail-rs/tests/e2e/trail.rs +++ b/audit-trail-rs/tests/e2e/trail.rs @@ -415,7 +415,7 @@ async fn manage_record_tag_registry_roundtrip() -> anyhow::Result<()> { let initial = trail.get().await?; assert_eq!(initial.available_record_tags.contents, vec!["finance".to_string()]); - trail.add_record_tag("legal").build_and_execute(&client).await?; + trail.tags().add("legal").build_and_execute(&client).await?; let after_add = trail.get().await?; assert!( after_add @@ -425,10 +425,6 @@ async fn manage_record_tag_registry_roundtrip() -> anyhow::Result<()> { ); assert!(after_add.available_record_tags.contents.contains(&"legal".to_string())); - trail - .set_record_tags(["finance", "hr"]) - .build_and_execute(&client) - .await?; let after_set = trail.get().await?; assert!( after_set @@ -439,7 +435,8 @@ async fn manage_record_tag_registry_roundtrip() -> anyhow::Result<()> { assert!(after_set.available_record_tags.contents.contains(&"hr".to_string())); assert!(!after_set.available_record_tags.contents.contains(&"legal".to_string())); - trail.remove_record_tag("hr").build_and_execute(&client).await?; + trail.tags().remove("hr").build_and_execute(&client).await?; + let after_remove = trail.get().await?; assert_eq!(after_remove.available_record_tags.contents, vec!["finance".to_string()]); @@ -477,7 +474,7 @@ async fn remove_record_tag_rejects_in_use_tag() -> anyhow::Result<()> { .build_and_execute(&client) .await?; - let removed = trail.remove_record_tag("finance").build_and_execute(&client).await; + let removed = trail.tags().remove("finance").build_and_execute(&client).await; assert!(removed.is_err(), "used record tags must not be removable"); Ok(()) From 859ecbaa6457dafcfd423fd8e07588c6b28bfb5b Mon Sep 17 00:00:00 2001 From: Yasir Date: Mon, 16 Mar 2026 15:47:10 +0300 Subject: [PATCH 074/189] chore: clean up audit tags --- audit-trail-move/sources/audit_trail.move | 39 +++++++------------ audit-trail-move/sources/record_tags.move | 18 ++++----- audit-trail-rs/src/core/records/operations.rs | 7 +--- audit-trail-rs/src/core/roles/operations.rs | 8 +--- audit-trail-rs/src/core/types/audit_trail.rs | 2 +- audit-trail-rs/tests/e2e/trail.rs | 14 +++---- 6 files changed, 34 insertions(+), 54 deletions(-) diff --git a/audit-trail-move/sources/audit_trail.move b/audit-trail-move/sources/audit_trail.move index f5d128ce..84f712c9 100644 --- a/audit-trail-move/sources/audit_trail.move +++ b/audit-trail-move/sources/audit_trail.move @@ -21,7 +21,7 @@ use audit_trail::{ record::{Self, Record}, record_tags::{Self, RecordTags} }; -use iota::{clock::{Self, Clock}, event, linked_table::{Self, LinkedTable}, vec_set::VecSet}; +use iota::{clock::{Self, Clock}, vec_set, event, linked_table::{Self, LinkedTable}, vec_set::VecSet}; use std::string::String; use tf_components::{capability::Capability, role_map::{Self, RoleMap}, timelock::TimeLock}; @@ -81,7 +81,7 @@ public struct AuditTrail has key, store { /// LinkedTable mapping sequence numbers to records records: LinkedTable>, /// Canonical list of tags that may be attached to records in this trail - available_record_tags: VecSet, + tags: VecSet, /// Deletion locking rules locking_config: LockingConfig, /// A list of role definitions consisting of a unique role specifier and a list of associated permissions @@ -157,7 +157,7 @@ public fun create( locking_config: LockingConfig, trail_metadata: Option, updatable_metadata: Option, - available_record_tags: vector, + tags: vector, clock: &Clock, ctx: &mut TxContext, ): (Capability, ID) { @@ -220,7 +220,7 @@ public fun create( created_at: timestamp, sequence_number, records, - available_record_tags: iota::vec_set::from_keys(available_record_tags), + tags: vec_set::from_keys(tags), locking_config, roles, immutable_metadata: trail_metadata, @@ -267,15 +267,12 @@ fun assert_record_tag_allowed( cap: &Capability, tag: &Option, ) { - if (!tag.is_some()) { + if (tag.is_none()) { return }; let requested_tag = option::borrow(tag); - assert!( - record_tags::is_defined(&self.available_record_tags, requested_tag), - ERecordTagNotDefined, - ); + assert!(record_tags::is_defined(&self.tags, requested_tag), ERecordTagNotDefined); assert!(record_tags::role_allows(&self.roles, cap, requested_tag), ERecordTagNotAllowed); } @@ -443,7 +440,7 @@ public fun delete_audit_trail( created_at: _, sequence_number: _, records, - available_record_tags: _, + tags: _, locking_config: _, roles: _, immutable_metadata: _, @@ -592,8 +589,8 @@ public fun add_record_tag( self.roles.assert_capability_valid(cap, &permission::add_record_tags(), clock, ctx); - assert!(!iota::vec_set::contains(&self.available_record_tags, &tag), ERecordTagAlreadyDefined); - self.available_record_tags.insert(tag); + assert!(!iota::vec_set::contains(&self.tags, &tag), ERecordTagAlreadyDefined); + self.tags.insert(tag); } /// Removes a record tag from the trail registry if it is not used by any record. @@ -608,9 +605,9 @@ public fun remove_record_tag( self.roles.assert_capability_valid(cap, &permission::delete_record_tags(), clock, ctx); - assert!(iota::vec_set::contains(&self.available_record_tags, &tag), ERecordTagNotDefined); + assert!(iota::vec_set::contains(&self.tags, &tag), ERecordTagNotDefined); assert!(!record_tags::is_in_use(&self.records, self.sequence_number, &tag), ERecordTagInUse); - self.available_record_tags.remove(&tag); + self.tags.remove(&tag); } // ===== Role and Capability Administration ===== @@ -627,10 +624,7 @@ public fun create_role( ) { assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); - assert!( - record_tags::defined_for_trail(&self.available_record_tags, &record_tags), - ERecordTagNotDefined, - ); + assert!(record_tags::defined_for_trail(&self.tags, &record_tags), ERecordTagNotDefined); role_map::create_role( self.roles_mut(), @@ -655,10 +649,7 @@ public fun update_role_permissions( ) { assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); - assert!( - record_tags::defined_for_trail(&self.available_record_tags, &record_tags), - ERecordTagNotDefined, - ); + assert!(record_tags::defined_for_trail(&self.tags, &record_tags), ERecordTagNotDefined); role_map::update_role( self.roles_mut(), cap, @@ -835,8 +826,8 @@ public fun locking_config(self: &AuditTrail): &LockingConfig } /// Get the trail-defined record tags. -public fun available_record_tags(self: &AuditTrail): &VecSet { - &self.available_record_tags +public fun tags(self: &AuditTrail): &VecSet { + &self.tags } /// Check if the trail is empty diff --git a/audit-trail-move/sources/record_tags.move b/audit-trail-move/sources/record_tags.move index 2a07dc9a..210bfb89 100644 --- a/audit-trail-move/sources/record_tags.move +++ b/audit-trail-move/sources/record_tags.move @@ -5,25 +5,25 @@ module audit_trail::record_tags; use audit_trail::{permission::Permission, record::{Self, Record}}; -use iota::{linked_table::{Self, LinkedTable}, vec_set::VecSet}; +use iota::{linked_table::{Self, LinkedTable}, vec_set::{Self, VecSet}}; use std::string::String; use tf_components::{capability::Capability, role_map::{Self, RoleMap}}; /// Stores all record tag related data associated with a role in the RoleMap. public struct RecordTags has copy, drop, store { - allowed_tags: VecSet, + tags: VecSet, } /// Create a role-scoped record-tag access list. -public fun new_record_tags(allowed_tags: vector): RecordTags { +public fun new_record_tags(tags: vector): RecordTags { RecordTags { - allowed_tags: iota::vec_set::from_keys(allowed_tags), + tags: vec_set::from_keys(tags), } } /// Get the allowlisted record tags for a role. public fun allowed_record_tags(record_tags: &RecordTags): &VecSet { - &record_tags.allowed_tags + &record_tags.tags } /// Returns true when all provided role tags are defined on the trail. @@ -35,8 +35,8 @@ public(package) fun defined_for_trail( return true }; - let allowed_tags = &option::borrow(record_tags).allowed_tags; - let allowed_tag_keys = iota::vec_set::keys(allowed_tags); + let tags = &option::borrow(record_tags).tags; + let allowed_tag_keys = iota::vec_set::keys(tags); let mut i = 0; let tag_count = allowed_tag_keys.length(); @@ -66,8 +66,8 @@ public(package) fun role_allows( return false }; - let allowed_tags = &option::borrow(role_tags).allowed_tags; - iota::vec_set::contains(allowed_tags, tag) + let tags = &option::borrow(role_tags).tags; + iota::vec_set::contains(tags, tag) } /// Returns true when any live record currently uses the provided tag. diff --git a/audit-trail-rs/src/core/records/operations.rs b/audit-trail-rs/src/core/records/operations.rs index efbab9e7..a7c864e0 100644 --- a/audit-trail-rs/src/core/records/operations.rs +++ b/audit-trail-rs/src/core/records/operations.rs @@ -26,12 +26,7 @@ impl RecordsOps { { if let Some(tag) = record_tag.clone() { let trail = operations::get_audit_trail(trail_id, client).await?; - if !trail - .available_record_tags - .contents - .iter() - .any(|allowed_tag| allowed_tag == &tag) - { + if !trail.tags.contents.iter().any(|allowed_tag| allowed_tag == &tag) { return Err(Error::InvalidArgument(format!( "record tag '{tag}' is not defined for trail {trail_id}" ))); diff --git a/audit-trail-rs/src/core/roles/operations.rs b/audit-trail-rs/src/core/roles/operations.rs index 620fb1e2..c54e73c6 100644 --- a/audit-trail-rs/src/core/roles/operations.rs +++ b/audit-trail-rs/src/core/roles/operations.rs @@ -277,13 +277,7 @@ where let undefined_tags = record_tags .allowed_tags .iter() - .filter(|tag| { - !trail - .available_record_tags - .contents - .iter() - .any(|available_tag| available_tag == *tag) - }) + .filter(|tag| !trail.tags.contents.iter().any(|available_tag| available_tag == *tag)) .cloned() .collect::>(); diff --git a/audit-trail-rs/src/core/types/audit_trail.rs b/audit-trail-rs/src/core/types/audit_trail.rs index 85210b66..5882fd31 100644 --- a/audit-trail-rs/src/core/types/audit_trail.rs +++ b/audit-trail-rs/src/core/types/audit_trail.rs @@ -25,7 +25,7 @@ pub struct OnChainAuditTrail { pub created_at: u64, pub sequence_number: u64, pub records: LinkedTable, - pub available_record_tags: VecSet, + pub tags: VecSet, pub locking_config: LockingConfig, pub roles: RoleMap, pub immutable_metadata: Option, diff --git a/audit-trail-rs/tests/e2e/trail.rs b/audit-trail-rs/tests/e2e/trail.rs index 15f3167a..b83fe6f0 100644 --- a/audit-trail-rs/tests/e2e/trail.rs +++ b/audit-trail-rs/tests/e2e/trail.rs @@ -413,32 +413,32 @@ async fn manage_record_tag_registry_roundtrip() -> anyhow::Result<()> { let trail = client.trail(created.trail_id); let initial = trail.get().await?; - assert_eq!(initial.available_record_tags.contents, vec!["finance".to_string()]); + assert_eq!(initial.tags.contents, vec!["finance".to_string()]); trail.tags().add("legal").build_and_execute(&client).await?; let after_add = trail.get().await?; assert!( after_add - .available_record_tags + .tags .contents .contains(&"finance".to_string()) ); - assert!(after_add.available_record_tags.contents.contains(&"legal".to_string())); + assert!(after_add.tags.contents.contains(&"legal".to_string())); let after_set = trail.get().await?; assert!( after_set - .available_record_tags + .tags .contents .contains(&"finance".to_string()) ); - assert!(after_set.available_record_tags.contents.contains(&"hr".to_string())); - assert!(!after_set.available_record_tags.contents.contains(&"legal".to_string())); + assert!(after_set.tags.contents.contains(&"hr".to_string())); + assert!(!after_set.tags.contents.contains(&"legal".to_string())); trail.tags().remove("hr").build_and_execute(&client).await?; let after_remove = trail.get().await?; - assert_eq!(after_remove.available_record_tags.contents, vec!["finance".to_string()]); + assert_eq!(after_remove.tags.contents, vec!["finance".to_string()]); Ok(()) } From 9790d553e8eff948b334149a61b82103939abaa5 Mon Sep 17 00:00:00 2001 From: Yasir Date: Mon, 16 Mar 2026 16:20:37 +0300 Subject: [PATCH 075/189] chore: refactor roles to access --- audit-trail-move/Move.lock | 14 +- audit-trail-move/sources/audit_trail.move | 26 ++-- audit-trail-move/tests/capability_tests.move | 142 +++++++++--------- .../tests/create_audit_trail_tests.move | 4 +- audit-trail-move/tests/locking_tests.move | 56 +++---- audit-trail-move/tests/metadata_tests.move | 14 +- audit-trail-move/tests/record_tests.move | 36 ++--- audit-trail-move/tests/role_tests.move | 72 ++++----- .../src/core/{roles => access}/mod.rs | 4 +- .../src/core/{roles => access}/operations.rs | 4 +- .../core/{roles => access}/transactions.rs | 18 +-- audit-trail-rs/src/core/mod.rs | 2 +- audit-trail-rs/src/core/trail.rs | 6 +- audit-trail-rs/src/package.rs | 1 + .../tests/e2e/{roles.rs => access.rs} | 54 +++---- audit-trail-rs/tests/e2e/client.rs | 4 +- audit-trail-rs/tests/e2e/main.rs | 2 +- 17 files changed, 230 insertions(+), 229 deletions(-) rename audit-trail-rs/src/core/{roles => access}/mod.rs (98%) rename audit-trail-rs/src/core/{roles => access}/operations.rs (99%) rename audit-trail-rs/src/core/{roles => access}/transactions.rs (96%) rename audit-trail-rs/tests/e2e/{roles.rs => access.rs} (81%) diff --git a/audit-trail-move/Move.lock b/audit-trail-move/Move.lock index c75ab983..3f3869a7 100644 --- a/audit-trail-move/Move.lock +++ b/audit-trail-move/Move.lock @@ -2,7 +2,7 @@ [move] version = 3 -manifest_digest = "A98478D66EC9631ABE28DCBEBAD8D608F813CA5A3D0856E6282E31F4FE7B20FF" +manifest_digest = "BBDC635C3E5B1F977F4F12056411AADB62CD398CFCA75919B69BE3414CFC8393" deps_digest = "397E6A9F7A624706DBDFEE056CE88391A15876868FD18A88504DA74EB458D697" dependencies = [ { id = "Iota", name = "Iota" }, @@ -14,7 +14,7 @@ dependencies = [ [[move.package]] id = "Iota" -source = { git = "https://github.com/iotaledger/iota.git", rev = "22dd2a89f5b591066c6930510a227c087cc836ac", subdir = "crates/iota-framework/packages/iota-framework" } +source = { git = "https://github.com/iotaledger/iota.git", rev = "431ab686e6f1d4abd83b16dd7c671712002ecac8", subdir = "crates/iota-framework/packages/iota-framework" } dependencies = [ { id = "MoveStdlib", name = "MoveStdlib" }, @@ -22,7 +22,7 @@ dependencies = [ [[move.package]] id = "IotaSystem" -source = { git = "https://github.com/iotaledger/iota.git", rev = "22dd2a89f5b591066c6930510a227c087cc836ac", subdir = "crates/iota-framework/packages/iota-system" } +source = { git = "https://github.com/iotaledger/iota.git", rev = "431ab686e6f1d4abd83b16dd7c671712002ecac8", subdir = "crates/iota-framework/packages/iota-system" } dependencies = [ { id = "Iota", name = "Iota" }, @@ -31,11 +31,11 @@ dependencies = [ [[move.package]] id = "MoveStdlib" -source = { git = "https://github.com/iotaledger/iota.git", rev = "22dd2a89f5b591066c6930510a227c087cc836ac", subdir = "crates/iota-framework/packages/move-stdlib" } +source = { git = "https://github.com/iotaledger/iota.git", rev = "431ab686e6f1d4abd83b16dd7c671712002ecac8", subdir = "crates/iota-framework/packages/move-stdlib" } [[move.package]] id = "Stardust" -source = { git = "https://github.com/iotaledger/iota.git", rev = "22dd2a89f5b591066c6930510a227c087cc836ac", subdir = "crates/iota-framework/packages/stardust" } +source = { git = "https://github.com/iotaledger/iota.git", rev = "431ab686e6f1d4abd83b16dd7c671712002ecac8", subdir = "crates/iota-framework/packages/stardust" } dependencies = [ { id = "Iota", name = "Iota" }, @@ -44,7 +44,7 @@ dependencies = [ [[move.package]] id = "TfComponents" -source = { local = "../../product-core/components_move" } +source = { git = "https://github.com/iotaledger/product-core.git", rev = "feat/tf-compoenents-dev", subdir = "components_move" } dependencies = [ { id = "Iota", name = "Iota" }, @@ -54,7 +54,7 @@ dependencies = [ ] [move.toolchain-version] -compiler-version = "1.17.2" +compiler-version = "1.18.1-rc" edition = "2024.beta" flavor = "iota" diff --git a/audit-trail-move/sources/audit_trail.move b/audit-trail-move/sources/audit_trail.move index c339f2d7..3da0cd22 100644 --- a/audit-trail-move/sources/audit_trail.move +++ b/audit-trail-move/sources/audit_trail.move @@ -567,7 +567,7 @@ public fun create_role( ) { assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); role_map::create_role( - trail.roles_mut(), + trail.access_mut(), cap, role, permissions, @@ -588,7 +588,7 @@ public fun update_role_permissions( ) { assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); role_map::update_role( - trail.roles_mut(), + trail.access_mut(), cap, &role, new_permissions, @@ -607,7 +607,7 @@ public fun delete_role( ctx: &mut TxContext, ) { assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); - role_map::delete_role(trail.roles_mut(), cap, &role, clock, ctx); + role_map::delete_role(trail.access_mut(), cap, &role, clock, ctx); } /// Issues a new capability for an existing role. @@ -633,7 +633,7 @@ public fun new_capability( }; let new_cap = role_map::new_capability( - trail.roles_mut(), + trail.access_mut(), cap, &role, issued_to, @@ -654,7 +654,7 @@ public fun revoke_capability( ctx: &mut TxContext, ) { assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); - role_map::revoke_capability(trail.roles_mut(), cap, capability_id, clock, ctx); + role_map::revoke_capability(trail.access_mut(), cap, capability_id, clock, ctx); } /// Destroys a capability object. @@ -676,7 +676,7 @@ public fun destroy_capability( clock, ctx, ); - role_map::destroy_capability(trail.roles_mut(), cap_to_destroy); + role_map::destroy_capability(trail.access_mut(), cap_to_destroy); } /// Destroys an initial admin capability. @@ -691,7 +691,7 @@ public fun destroy_initial_admin_capability( cap_to_destroy: Capability, ) { assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); - role_map::destroy_initial_admin_capability(trail.roles_mut(), cap_to_destroy); + role_map::destroy_initial_admin_capability(trail.access_mut(), cap_to_destroy); } /// Revokes an initial admin capability by ID. @@ -708,7 +708,7 @@ public fun revoke_initial_admin_capability( ctx: &mut TxContext, ) { assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); - role_map::revoke_initial_admin_capability(trail.roles_mut(), cap, capability_id, clock, ctx); + role_map::revoke_initial_admin_capability(trail.access_mut(), cap, capability_id, clock, ctx); } // ===== Trail Query Functions ===== @@ -797,16 +797,16 @@ public fun records(trail: &AuditTrail): &LinkedTable(trail: &AuditTrail): &RoleMap { +/// Returns the RoleMap managing access for the audit trail. +public fun access(trail: &AuditTrail): &RoleMap { assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); &trail.roles } -/// Returns a mutable reference to the RoleMap managing the roles and capabilities used in the audit trail -public fun roles_mut(trail: &mut AuditTrail): &mut RoleMap { +/// Returns a mutable reference to the RoleMap managing access for the audit trail. +public fun access_mut(trail: &mut AuditTrail): &mut RoleMap { assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); &mut trail.roles } diff --git a/audit-trail-move/tests/capability_tests.move b/audit-trail-move/tests/capability_tests.move index 50e9cc16..43640af9 100644 --- a/audit-trail-move/tests/capability_tests.move +++ b/audit-trail-move/tests/capability_tests.move @@ -37,7 +37,7 @@ fun setup_trail_with_record_admin_capability_and_time_window_restriction( let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(scenario); let cap = trail - .roles_mut() + .access_mut() .new_capability( &admin_cap, &string::utf8(b"RecordAdmin"), @@ -90,7 +90,7 @@ fun setup_trail_with_record_admin_role(scenario: &mut Scenario, admin_user: addr let record_admin_perms = permission::record_admin_permissions(); trail - .roles_mut() + .access_mut() .create_role( &admin_cap, string::utf8(b"RecordAdmin"), @@ -137,7 +137,7 @@ fun test_new_capability() { let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); trail - .roles_mut() + .access_mut() .create_role( &admin_cap, string::utf8(b"RecordAdmin"), @@ -156,11 +156,11 @@ fun test_new_capability() { let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); // Verify initial state - only admin capability should be tracked - let initial_cap_count = trail.roles().issued_capabilities().size(); + let initial_cap_count = trail.access().issued_capabilities().size(); assert!(initial_cap_count == 1, 0); // Only admin cap let cap1 = test_utils::new_capability_without_restrictions( - trail.roles_mut(), + trail.access_mut(), &admin_cap, &string::utf8(b"RecordAdmin"), &clock, @@ -172,8 +172,8 @@ fun test_new_capability() { let cap1_id = object::id(&cap1); // Verify capability ID is tracked in issued_capabilities - assert!(trail.roles().issued_capabilities().size() == initial_cap_count + 1, 3); - assert!(trail.roles().issued_capabilities().contains(&cap1_id), 4); + assert!(trail.access().issued_capabilities().size() == initial_cap_count + 1, 3); + assert!(trail.access().issued_capabilities().contains(&cap1_id), 4); transfer::public_transfer(cap1, user1); cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); @@ -186,10 +186,10 @@ fun test_new_capability() { let _cap2_id = { let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); - let previous_cap_count = trail.roles().issued_capabilities().size(); + let previous_cap_count = trail.access().issued_capabilities().size(); let cap2 = test_utils::new_capability_without_restrictions( - trail.roles_mut(), + trail.access_mut(), &admin_cap, &string::utf8(b"RecordAdmin"), &clock, @@ -199,9 +199,9 @@ fun test_new_capability() { let cap2_id = object::id(&cap2); // Verify both capabilities are tracked - assert!(trail.roles().issued_capabilities().size() == previous_cap_count + 1, 5); - assert!(trail.roles().issued_capabilities().contains(&cap1_id), 6); - assert!(trail.roles().issued_capabilities().contains(&cap2_id), 7); + assert!(trail.access().issued_capabilities().size() == previous_cap_count + 1, 5); + assert!(trail.access().issued_capabilities().contains(&cap1_id), 6); + assert!(trail.access().issued_capabilities().contains(&cap2_id), 7); // Verify capabilities have unique IDs assert!(cap1_id != cap2_id, 8); @@ -231,7 +231,7 @@ fun test_revoke_capability() { let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); let cap1 = test_utils::new_capability_without_restrictions( - trail.roles_mut(), + trail.access_mut(), &admin_cap, &string::utf8(b"RecordAdmin"), &clock, @@ -241,7 +241,7 @@ fun test_revoke_capability() { transfer::public_transfer(cap1, user1); let cap2 = test_utils::new_capability_without_restrictions( - trail.roles_mut(), + trail.access_mut(), &admin_cap, &string::utf8(b"RecordAdmin"), &clock, @@ -262,13 +262,13 @@ fun test_revoke_capability() { let cap1 = ts::take_from_address(&scenario, user1); // Verify both capabilities are tracked before revocation - let cap_count_before = trail.roles().issued_capabilities().size(); - assert!(trail.roles().issued_capabilities().contains(&cap1_id), 0); - assert!(trail.roles().issued_capabilities().contains(&cap2_id), 1); + let cap_count_before = trail.access().issued_capabilities().size(); + assert!(trail.access().issued_capabilities().contains(&cap1_id), 0); + assert!(trail.access().issued_capabilities().contains(&cap2_id), 1); // Revoke the capability trail - .roles_mut() + .access_mut() .revoke_capability( &admin_cap, cap1.id(), @@ -277,10 +277,10 @@ fun test_revoke_capability() { ); // Verify capability was removed from tracking - assert!(trail.roles().issued_capabilities().size() == cap_count_before - 1, 2); - assert!(!trail.roles().issued_capabilities().contains(&cap1_id), 3); + assert!(trail.access().issued_capabilities().size() == cap_count_before - 1, 2); + assert!(!trail.access().issued_capabilities().contains(&cap1_id), 3); // Verify other capability is still tracked - assert!(trail.roles().issued_capabilities().contains(&cap2_id), 4); + assert!(trail.access().issued_capabilities().contains(&cap2_id), 4); ts::return_to_address(user1, cap1); cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); @@ -298,10 +298,10 @@ fun test_revoke_capability() { let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); let cap2 = ts::take_from_address(&scenario, user2); - let cap_count_before = trail.roles().issued_capabilities().size(); + let cap_count_before = trail.access().issued_capabilities().size(); trail - .roles_mut() + .access_mut() .revoke_capability( &admin_cap, cap2.id(), @@ -310,8 +310,8 @@ fun test_revoke_capability() { ); // Verify capability was removed from tracking - assert!(trail.roles().issued_capabilities().size() == cap_count_before - 1, 6); - assert!(!trail.roles().issued_capabilities().contains(&cap2_id), 7); + assert!(trail.access().issued_capabilities().size() == cap_count_before - 1, 6); + assert!(!trail.access().issued_capabilities().contains(&cap2_id), 7); ts::return_to_address(user2, cap2); cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); @@ -336,7 +336,7 @@ fun test_destroy_capability() { let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); let cap1 = test_utils::new_capability_without_restrictions( - trail.roles_mut(), + trail.access_mut(), &admin_cap, &string::utf8(b"RecordAdmin"), &clock, @@ -346,7 +346,7 @@ fun test_destroy_capability() { transfer::public_transfer(cap1, user1); let cap2 = test_utils::new_capability_without_restrictions( - trail.roles_mut(), + trail.access_mut(), &admin_cap, &string::utf8(b"RecordAdmin"), &clock, @@ -367,19 +367,19 @@ fun test_destroy_capability() { let cap1 = ts::take_from_sender(&scenario); // Verify both capabilities are tracked before destruction - let cap_count_before = trail.roles().issued_capabilities().size(); - assert!(trail.roles().issued_capabilities().contains(&cap1_id), 0); - assert!(trail.roles().issued_capabilities().contains(&cap2_id), 1); + let cap_count_before = trail.access().issued_capabilities().size(); + assert!(trail.access().issued_capabilities().contains(&cap1_id), 0); + assert!(trail.access().issued_capabilities().contains(&cap2_id), 1); // Destroy the capability - trail.roles_mut().destroy_capability(cap1); + trail.access_mut().destroy_capability(cap1); // Verify capability was removed from tracking - assert!(trail.roles().issued_capabilities().size() == cap_count_before - 1, 2); - assert!(!trail.roles().issued_capabilities().contains(&cap1_id), 3); + assert!(trail.access().issued_capabilities().size() == cap_count_before - 1, 2); + assert!(!trail.access().issued_capabilities().contains(&cap1_id), 3); // Verify other capability is still tracked - assert!(trail.roles().issued_capabilities().contains(&cap2_id), 4); + assert!(trail.access().issued_capabilities().contains(&cap2_id), 4); ts::return_shared(trail); }; @@ -396,13 +396,13 @@ fun test_destroy_capability() { let mut trail = ts::take_shared>(&scenario); let cap2 = ts::take_from_sender(&scenario); - let cap_count_before = trail.roles().issued_capabilities().size(); + let cap_count_before = trail.access().issued_capabilities().size(); - trail.roles_mut().destroy_capability(cap2); + trail.access_mut().destroy_capability(cap2); // Verify capability was removed from tracking - assert!(trail.roles().issued_capabilities().size() == cap_count_before - 1, 6); - assert!(!trail.roles().issued_capabilities().contains(&cap2_id), 7); + assert!(trail.access().issued_capabilities().size() == cap_count_before - 1, 6); + assert!(!trail.access().issued_capabilities().contains(&cap2_id), 7); ts::return_shared(trail); }; @@ -413,7 +413,7 @@ fun test_destroy_capability() { let trail = ts::take_shared>(&scenario); // Only the initial admin capability should remain - assert!(trail.roles().issued_capabilities().size() == 1, 8); + assert!(trail.access().issued_capabilities().size() == 1, 8); ts::return_shared(trail); }; @@ -445,10 +445,10 @@ fun test_capability_lifecycle() { let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); // Initially only admin cap should be tracked - assert!(trail.roles().issued_capabilities().size() == 1, 0); + assert!(trail.access().issued_capabilities().size() == 1, 0); trail - .roles_mut() + .access_mut() .create_role( &admin_cap, string::utf8(b"RoleAdmin"), @@ -467,7 +467,7 @@ fun test_capability_lifecycle() { let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); let record_cap = test_utils::new_capability_without_restrictions( - trail.roles_mut(), + trail.access_mut(), &admin_cap, &string::utf8(b"RecordAdmin"), &clock, @@ -477,7 +477,7 @@ fun test_capability_lifecycle() { transfer::public_transfer(record_cap, record_admin_user); let role_cap = test_utils::new_capability_without_restrictions( - trail.roles_mut(), + trail.access_mut(), &admin_cap, &string::utf8(b"RoleAdmin"), &clock, @@ -487,9 +487,9 @@ fun test_capability_lifecycle() { transfer::public_transfer(role_cap, role_admin_user); // Verify all capabilities are tracked - assert!(trail.roles().issued_capabilities().size() == 3, 1); // admin + record + role - assert!(trail.roles().issued_capabilities().contains(&record_cap_id), 2); - assert!(trail.roles().issued_capabilities().contains(&role_cap_id), 3); + assert!(trail.access().issued_capabilities().size() == 3, 1); // admin + record + role + assert!(trail.access().issued_capabilities().contains(&record_cap_id), 2); + assert!(trail.access().issued_capabilities().contains(&role_cap_id), 3); cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); @@ -521,11 +521,11 @@ fun test_capability_lifecycle() { let mut trail = ts::take_shared>(&scenario); let record_cap = ts::take_from_sender(&scenario); - trail.roles_mut().destroy_capability(record_cap); + trail.access_mut().destroy_capability(record_cap); // Verify capability was removed - assert!(trail.roles().issued_capabilities().size() == 2, 4); // admin + role - assert!(!trail.roles().issued_capabilities().contains(&record_cap_id), 5); + assert!(trail.access().issued_capabilities().size() == 2, 4); // admin + role + assert!(!trail.access().issued_capabilities().contains(&record_cap_id), 5); ts::return_shared(trail); }; @@ -537,7 +537,7 @@ fun test_capability_lifecycle() { let role_cap = ts::take_from_address(&scenario, role_admin_user); trail - .roles_mut() + .access_mut() .revoke_capability( &admin_cap, role_cap.id(), @@ -546,8 +546,8 @@ fun test_capability_lifecycle() { ); // Verify capability was removed - assert!(trail.roles().issued_capabilities().size() == 1, 6); // only admin remains - assert!(!trail.roles().issued_capabilities().contains(&role_cap_id), 7); + assert!(trail.access().issued_capabilities().size() == 1, 6); // only admin remains + assert!(!trail.access().issued_capabilities().contains(&role_cap_id), 7); ts::return_to_address(role_admin_user, role_cap); @@ -573,7 +573,7 @@ fun test_capability_issued_to_only() { let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); let cap = test_utils::new_capability_for_address( - trail.roles_mut(), + trail.access_mut(), &admin_cap, &string::utf8(b"RecordAdmin"), authorized_user, @@ -664,7 +664,7 @@ fun test_revoked_capability_cannot_be_used() { let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); trail - .roles_mut() + .access_mut() .create_role( &admin_cap, string::utf8(b"RecordAdmin"), @@ -675,7 +675,7 @@ fun test_revoked_capability_cannot_be_used() { ); let user_cap = test_utils::new_capability_without_restrictions( - trail.roles_mut(), + trail.access_mut(), &admin_cap, &string::utf8(b"RecordAdmin"), &clock, @@ -693,7 +693,7 @@ fun test_revoked_capability_cannot_be_used() { let user_cap = ts::take_from_address(&scenario, user); trail - .roles_mut() + .access_mut() .revoke_capability(&admin_cap, user_cap.id(), &clock, ts::ctx(&mut scenario)); ts::return_to_address(user, user_cap); @@ -747,7 +747,7 @@ fun test_new_capability_for_nonexistent_role() { let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); let bad_cap = test_utils::new_capability_without_restrictions( - trail.roles_mut(), + trail.access_mut(), &admin_cap, &string::utf8(b"NonExistentRole"), &clock, @@ -791,7 +791,7 @@ fun test_revoke_capability_permission_denied() { let perms = permission::from_vec(vector[permission::add_record()]); trail - .roles_mut() + .access_mut() .create_role( &admin_cap, string::utf8(b"NoRevokePerm"), @@ -802,7 +802,7 @@ fun test_revoke_capability_permission_denied() { ); trail - .roles_mut() + .access_mut() .create_role( &admin_cap, string::utf8(b"RecordAdmin"), @@ -813,7 +813,7 @@ fun test_revoke_capability_permission_denied() { ); let user1_cap = test_utils::new_capability_without_restrictions( - trail.roles_mut(), + trail.access_mut(), &admin_cap, &string::utf8(b"NoRevokePerm"), &clock, @@ -821,7 +821,7 @@ fun test_revoke_capability_permission_denied() { ); let user2_cap = test_utils::new_capability_without_restrictions( - trail.roles_mut(), + trail.access_mut(), &admin_cap, &string::utf8(b"RecordAdmin"), &clock, @@ -842,7 +842,7 @@ fun test_revoke_capability_permission_denied() { let clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); trail - .roles_mut() + .access_mut() .revoke_capability(&user1_cap, user2_cap.id(), &clock, ts::ctx(&mut scenario)); ts::return_to_address(user2, user2_cap); @@ -883,7 +883,7 @@ fun test_new_capability_permission_denied() { let perms = permission::from_vec(vector[permission::add_record()]); trail - .roles_mut() + .access_mut() .create_role( &admin_cap, string::utf8(b"NoCapPerm"), @@ -894,7 +894,7 @@ fun test_new_capability_permission_denied() { ); trail - .roles_mut() + .access_mut() .create_role( &admin_cap, string::utf8(b"RecordAdmin"), @@ -905,7 +905,7 @@ fun test_new_capability_permission_denied() { ); let user_cap = test_utils::new_capability_without_restrictions( - trail.roles_mut(), + trail.access_mut(), &admin_cap, &string::utf8(b"NoCapPerm"), &clock, @@ -922,7 +922,7 @@ fun test_new_capability_permission_denied() { let (user_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); let new_cap = test_utils::new_capability_without_restrictions( - trail.roles_mut(), + trail.access_mut(), &user_cap, &string::utf8(b"RecordAdmin"), &clock, @@ -960,7 +960,7 @@ fun test_capability_valid_from_only() { let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); let cap = trail - .roles_mut() + .access_mut() .new_capability( &admin_cap, &string::utf8(b"RecordAdmin"), @@ -1044,7 +1044,7 @@ fun test_capability_valid_until_only() { let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); let cap = test_utils::new_capability_valid_until( - trail.roles_mut(), + trail.access_mut(), &admin_cap, &string::utf8(b"RecordAdmin"), valid_until_time_ms, @@ -1261,7 +1261,7 @@ fun test_is_valid_for_timestamp() { let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); let cap = trail - .roles_mut() + .access_mut() .new_capability( &admin_cap, &string::utf8(b"RecordAdmin"), @@ -1300,7 +1300,7 @@ fun test_is_valid_for_timestamp() { let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); let unrestricted_cap = test_utils::new_capability_without_restrictions( - trail.roles_mut(), + trail.access_mut(), &admin_cap, &string::utf8(b"RecordAdmin"), &clock, @@ -1345,7 +1345,7 @@ fun test_is_currently_valid() { let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); let cap = trail - .roles_mut() + .access_mut() .new_capability( &admin_cap, &string::utf8(b"RecordAdmin"), diff --git a/audit-trail-move/tests/create_audit_trail_tests.move b/audit-trail-move/tests/create_audit_trail_tests.move index 70cca95e..f01fa9a7 100644 --- a/audit-trail-move/tests/create_audit_trail_tests.move +++ b/audit-trail-move/tests/create_audit_trail_tests.move @@ -274,7 +274,7 @@ fun test_create_metadata_admin_role() { let metadata_admin_perms = audit_trail::permission::metadata_admin_permissions(); trail - .roles_mut() + .access_mut() .create_role( &admin_cap, metadata_admin_role_name, @@ -285,7 +285,7 @@ fun test_create_metadata_admin_role() { ); // Verify the role was created by fetching its permissions - let role_perms = trail.roles().get_role_permissions(&string::utf8(b"MetadataAdmin")); + let role_perms = trail.access().get_role_permissions(&string::utf8(b"MetadataAdmin")); // Verify the role has the correct permissions assert!( diff --git a/audit-trail-move/tests/locking_tests.move b/audit-trail-move/tests/locking_tests.move index eca383a6..611ea0bc 100644 --- a/audit-trail-move/tests/locking_tests.move +++ b/audit-trail-move/tests/locking_tests.move @@ -135,7 +135,7 @@ fun test_count_based_locking() { let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); trail - .roles_mut() + .access_mut() .create_role( &admin_cap, string::utf8(b"RecordAdmin"), @@ -146,7 +146,7 @@ fun test_count_based_locking() { ); let record_cap = test_utils::new_capability_without_restrictions( - trail.roles_mut(), + trail.access_mut(), &admin_cap, &string::utf8(b"RecordAdmin"), &clock, @@ -297,7 +297,7 @@ fun test_update_locking_config() { let perms = permission::from_vec(vector[permission::update_locking_config()]); trail - .roles_mut() + .access_mut() .create_role( &admin_cap, string::utf8(b"LockingAdmin"), @@ -308,7 +308,7 @@ fun test_update_locking_config() { ); let locking_cap = test_utils::new_capability_without_restrictions( - trail.roles_mut(), + trail.access_mut(), &admin_cap, &string::utf8(b"LockingAdmin"), &clock, @@ -374,7 +374,7 @@ fun test_update_locking_config_permission_denied() { let perms = permission::from_vec(vector[permission::add_record()]); trail - .roles_mut() + .access_mut() .create_role( &admin_cap, string::utf8(b"NoLockingPerm"), @@ -385,7 +385,7 @@ fun test_update_locking_config_permission_denied() { ); let no_locking_cap = test_utils::new_capability_without_restrictions( - trail.roles_mut(), + trail.access_mut(), &admin_cap, &string::utf8(b"NoLockingPerm"), &clock, @@ -443,7 +443,7 @@ fun test_update_delete_record_window() { permission::update_locking_config_for_delete_record(), ]); trail - .roles_mut() + .access_mut() .create_role( &admin_cap, string::utf8(b"DeleteLockAdmin"), @@ -454,7 +454,7 @@ fun test_update_delete_record_window() { ); let delete_lock_cap = test_utils::new_capability_without_restrictions( - trail.roles_mut(), + trail.access_mut(), &admin_cap, &string::utf8(b"DeleteLockAdmin"), &clock, @@ -521,7 +521,7 @@ fun test_update_delete_record_window_permission_denied() { let perms = permission::from_vec(vector[permission::update_locking_config()]); trail - .roles_mut() + .access_mut() .create_role( &admin_cap, string::utf8(b"WrongPerm"), @@ -532,7 +532,7 @@ fun test_update_delete_record_window_permission_denied() { ); let wrong_cap = test_utils::new_capability_without_restrictions( - trail.roles_mut(), + trail.access_mut(), &admin_cap, &string::utf8(b"WrongPerm"), &clock, @@ -588,7 +588,7 @@ fun test_delete_record_after_time_lock_expires() { let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); trail - .roles_mut() + .access_mut() .create_role( &admin_cap, string::utf8(b"RecordAdmin"), @@ -599,7 +599,7 @@ fun test_delete_record_after_time_lock_expires() { ); let record_cap = test_utils::new_capability_without_restrictions( - trail.roles_mut(), + trail.access_mut(), &admin_cap, &string::utf8(b"RecordAdmin"), &clock, @@ -719,7 +719,7 @@ fun test_time_based_locking_all_recent_records_locked() { let (admin_cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); trail - .roles_mut() + .access_mut() .create_role( &admin_cap, string::utf8(b"RecordAdmin"), @@ -730,7 +730,7 @@ fun test_time_based_locking_all_recent_records_locked() { ); let record_cap = test_utils::new_capability_without_restrictions( - trail.roles_mut(), + trail.access_mut(), &admin_cap, &string::utf8(b"RecordAdmin"), &clock, @@ -805,7 +805,7 @@ fun test_count_based_locking_last_records_remain_locked() { let (admin_cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); trail - .roles_mut() + .access_mut() .create_role( &admin_cap, string::utf8(b"RecordAdmin"), @@ -816,7 +816,7 @@ fun test_count_based_locking_last_records_remain_locked() { ); let record_cap = test_utils::new_capability_without_restrictions( - trail.roles_mut(), + trail.access_mut(), &admin_cap, &string::utf8(b"RecordAdmin"), &clock, @@ -893,7 +893,7 @@ fun test_time_based_locking_still_locked_before_expiry() { let (admin_cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); trail - .roles_mut() + .access_mut() .create_role( &admin_cap, string::utf8(b"RecordAdmin"), @@ -904,7 +904,7 @@ fun test_time_based_locking_still_locked_before_expiry() { ); let record_cap = test_utils::new_capability_without_restrictions( - trail.roles_mut(), + trail.access_mut(), &admin_cap, &string::utf8(b"RecordAdmin"), &clock, @@ -977,7 +977,7 @@ fun test_count_based_locking_old_record_can_delete() { let (admin_cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); trail - .roles_mut() + .access_mut() .create_role( &admin_cap, string::utf8(b"RecordAdmin"), @@ -988,7 +988,7 @@ fun test_count_based_locking_old_record_can_delete() { ); let record_cap = test_utils::new_capability_without_restrictions( - trail.roles_mut(), + trail.access_mut(), &admin_cap, &string::utf8(b"RecordAdmin"), &clock, @@ -1071,7 +1071,7 @@ fun test_delete_records_batch_bypasses_record_lock() { let delete_all_perms = permission::from_vec(vector[permission::delete_all_records()]); trail - .roles_mut() + .access_mut() .create_role( &admin_cap, delete_all_role, @@ -1082,7 +1082,7 @@ fun test_delete_records_batch_bypasses_record_lock() { ); let delete_all_cap = test_utils::new_capability_without_restrictions( - trail.roles_mut(), + trail.access_mut(), &admin_cap, &string::utf8(b"DeleteAllRecordsAdmin"), &clock, @@ -1134,7 +1134,7 @@ fun test_delete_records_batch_requires_delete_all_records_permission() { let perms = permission::from_vec(vector[permission::delete_audit_trail()]); trail - .roles_mut() + .access_mut() .create_role( &admin_cap, string::utf8(b"TrailDeleteOnly"), @@ -1145,7 +1145,7 @@ fun test_delete_records_batch_requires_delete_all_records_permission() { ); let delete_only_cap = test_utils::new_capability_without_restrictions( - trail.roles_mut(), + trail.access_mut(), &admin_cap, &string::utf8(b"TrailDeleteOnly"), &clock, @@ -1204,7 +1204,7 @@ fun test_delete_audit_trail_fails_while_not_empty() { let delete_trail_role = string::utf8(b"DeleteTrailOnly"); let delete_trail_perms = permission::from_vec(vector[permission::delete_audit_trail()]); trail - .roles_mut() + .access_mut() .create_role( &admin_cap, delete_trail_role, @@ -1215,7 +1215,7 @@ fun test_delete_audit_trail_fails_while_not_empty() { ); let delete_trail_cap = test_utils::new_capability_without_restrictions( - trail.roles_mut(), + trail.access_mut(), &admin_cap, &string::utf8(b"DeleteTrailOnly"), &clock, @@ -1263,7 +1263,7 @@ fun test_delete_audit_trail_after_batch_cleanup() { ]); trail - .roles_mut() + .access_mut() .create_role( &admin_cap, delete_maintenance_role, @@ -1274,7 +1274,7 @@ fun test_delete_audit_trail_after_batch_cleanup() { ); let delete_maintenance_cap = test_utils::new_capability_without_restrictions( - trail.roles_mut(), + trail.access_mut(), &admin_cap, &string::utf8(b"DeleteMaintenance"), &clock, diff --git a/audit-trail-move/tests/metadata_tests.move b/audit-trail-move/tests/metadata_tests.move index d23eb38c..f690efb4 100644 --- a/audit-trail-move/tests/metadata_tests.move +++ b/audit-trail-move/tests/metadata_tests.move @@ -48,7 +48,7 @@ fun test_update_metadata_success() { // Create MetadataAdmin role with metadata permissions let metadata_perms = permission::metadata_admin_permissions(); trail - .roles_mut() + .access_mut() .create_role( &admin_cap, string::utf8(b"MetadataAdmin"), @@ -60,7 +60,7 @@ fun test_update_metadata_success() { // Issue capability to metadata admin user let metadata_cap = test_utils::new_capability_without_restrictions( - trail.roles_mut(), + trail.access_mut(), &admin_cap, &string::utf8(b"MetadataAdmin"), &clock, @@ -171,7 +171,7 @@ fun test_update_metadata_permission_denied() { // Create role with only add_record permission (no update_metadata) let perms = permission::from_vec(vector[permission::add_record()]); trail - .roles_mut() + .access_mut() .create_role( &admin_cap, string::utf8(b"NoMetadataPerm"), @@ -182,7 +182,7 @@ fun test_update_metadata_permission_denied() { ); let user_cap = test_utils::new_capability_without_restrictions( - trail.roles_mut(), + trail.access_mut(), &admin_cap, &string::utf8(b"NoMetadataPerm"), &clock, @@ -243,7 +243,7 @@ fun test_update_metadata_revoked_capability() { // Create MetadataAdmin role let metadata_perms = permission::metadata_admin_permissions(); trail - .roles_mut() + .access_mut() .create_role( &admin_cap, string::utf8(b"MetadataAdmin"), @@ -255,7 +255,7 @@ fun test_update_metadata_revoked_capability() { // Issue capability let metadata_cap = test_utils::new_capability_without_restrictions( - trail.roles_mut(), + trail.access_mut(), &admin_cap, &string::utf8(b"MetadataAdmin"), &clock, @@ -273,7 +273,7 @@ fun test_update_metadata_revoked_capability() { let metadata_cap = ts::take_from_address(&scenario, metadata_admin_user); trail - .roles_mut() + .access_mut() .revoke_capability(&admin_cap, metadata_cap.id(), &clock, ts::ctx(&mut scenario)); ts::return_to_address(metadata_admin_user, metadata_cap); diff --git a/audit-trail-move/tests/record_tests.move b/audit-trail-move/tests/record_tests.move index 400f2dfa..6ca8c84f 100644 --- a/audit-trail-move/tests/record_tests.move +++ b/audit-trail-move/tests/record_tests.move @@ -51,7 +51,7 @@ fun test_add_record_to_empty_trail() { let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); trail - .roles_mut() + .access_mut() .create_role( &admin_cap, string::utf8(b"RecordAdmin"), @@ -62,7 +62,7 @@ fun test_add_record_to_empty_trail() { ); let record_cap = test_utils::new_capability_without_restrictions( - trail.roles_mut(), + trail.access_mut(), &admin_cap, &string::utf8(b"RecordAdmin"), &clock, @@ -130,7 +130,7 @@ fun test_add_multiple_records() { let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); trail - .roles_mut() + .access_mut() .create_role( &admin_cap, string::utf8(b"RecordAdmin"), @@ -141,7 +141,7 @@ fun test_add_multiple_records() { ); let record_cap = test_utils::new_capability_without_restrictions( - trail.roles_mut(), + trail.access_mut(), &admin_cap, &string::utf8(b"RecordAdmin"), &clock, @@ -213,7 +213,7 @@ fun test_add_record_permission_denied() { let perms = permission::from_vec(vector[permission::delete_record()]); trail - .roles_mut() + .access_mut() .create_role( &admin_cap, string::utf8(b"NoAddPerm"), @@ -224,7 +224,7 @@ fun test_add_record_permission_denied() { ); let no_add_cap = test_utils::new_capability_without_restrictions( - trail.roles_mut(), + trail.access_mut(), &admin_cap, &string::utf8(b"NoAddPerm"), &clock, @@ -285,7 +285,7 @@ fun test_delete_record_success() { let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); trail - .roles_mut() + .access_mut() .create_role( &admin_cap, string::utf8(b"RecordAdmin"), @@ -296,7 +296,7 @@ fun test_delete_record_success() { ); let record_cap = test_utils::new_capability_without_restrictions( - trail.roles_mut(), + trail.access_mut(), &admin_cap, &string::utf8(b"RecordAdmin"), &clock, @@ -360,7 +360,7 @@ fun test_delete_record_permission_denied() { let perms = permission::from_vec(vector[permission::add_record()]); trail - .roles_mut() + .access_mut() .create_role( &admin_cap, string::utf8(b"NoDeletePerm"), @@ -371,7 +371,7 @@ fun test_delete_record_permission_denied() { ); let no_delete_cap = test_utils::new_capability_without_restrictions( - trail.roles_mut(), + trail.access_mut(), &admin_cap, &string::utf8(b"NoDeletePerm"), &clock, @@ -425,7 +425,7 @@ fun test_delete_record_not_found() { let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); trail - .roles_mut() + .access_mut() .create_role( &admin_cap, string::utf8(b"RecordAdmin"), @@ -436,7 +436,7 @@ fun test_delete_record_not_found() { ); let record_cap = test_utils::new_capability_without_restrictions( - trail.roles_mut(), + trail.access_mut(), &admin_cap, &string::utf8(b"RecordAdmin"), &clock, @@ -490,7 +490,7 @@ fun test_delete_record_time_locked() { let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); trail - .roles_mut() + .access_mut() .create_role( &admin_cap, string::utf8(b"RecordAdmin"), @@ -501,7 +501,7 @@ fun test_delete_record_time_locked() { ); let record_cap = test_utils::new_capability_without_restrictions( - trail.roles_mut(), + trail.access_mut(), &admin_cap, &string::utf8(b"RecordAdmin"), &clock, @@ -556,7 +556,7 @@ fun test_delete_record_count_locked() { let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); trail - .roles_mut() + .access_mut() .create_role( &admin_cap, string::utf8(b"RecordAdmin"), @@ -567,7 +567,7 @@ fun test_delete_record_count_locked() { ); let record_cap = test_utils::new_capability_without_restrictions( - trail.roles_mut(), + trail.access_mut(), &admin_cap, &string::utf8(b"RecordAdmin"), &clock, @@ -697,7 +697,7 @@ fun test_first_last_sequence() { assert!(trail.last_sequence().is_none(), 1); trail - .roles_mut() + .access_mut() .create_role( &admin_cap, string::utf8(b"RecordAdmin"), @@ -708,7 +708,7 @@ fun test_first_last_sequence() { ); let record_cap = test_utils::new_capability_without_restrictions( - trail.roles_mut(), + trail.access_mut(), &admin_cap, &string::utf8(b"RecordAdmin"), &clock, diff --git a/audit-trail-move/tests/role_tests.move b/audit-trail-move/tests/role_tests.move index 2bb32ea8..688bae38 100644 --- a/audit-trail-move/tests/role_tests.move +++ b/audit-trail-move/tests/role_tests.move @@ -57,12 +57,12 @@ fun test_role_based_permission_delegation() { let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); // Verify initial state - should only have the initial admin role - assert!(trail.roles().size() == 1, 2); + assert!(trail.access().size() == 1, 2); // Create RoleAdmin role let role_admin_perms = permission::role_admin_permissions(); trail - .roles_mut() + .access_mut() .create_role( &admin_cap, string::utf8(b"RoleAdmin"), @@ -75,7 +75,7 @@ fun test_role_based_permission_delegation() { // Create CapAdmin role let cap_admin_perms = permission::cap_admin_permissions(); trail - .roles_mut() + .access_mut() .create_role( &admin_cap, string::utf8(b"CapAdmin"), @@ -86,9 +86,9 @@ fun test_role_based_permission_delegation() { ); // Verify both roles were created - assert!(trail.roles().size() == 3, 3); // Initial admin + RoleAdmin + CapAdmin - assert!(trail.roles().has_role(&string::utf8(b"RoleAdmin")), 4); - assert!(trail.roles().has_role(&string::utf8(b"CapAdmin")), 5); + assert!(trail.access().size() == 3, 3); // Initial admin + RoleAdmin + CapAdmin + assert!(trail.access().has_role(&string::utf8(b"RoleAdmin")), 4); + assert!(trail.access().has_role(&string::utf8(b"CapAdmin")), 5); cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); }; @@ -99,7 +99,7 @@ fun test_role_based_permission_delegation() { let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); let role_admin_cap = test_utils::new_capability_without_restrictions( - trail.roles_mut(), + trail.access_mut(), &admin_cap, &string::utf8(b"RoleAdmin"), &clock, @@ -113,7 +113,7 @@ fun test_role_based_permission_delegation() { iota::transfer::public_transfer(role_admin_cap, role_admin_user); let cap_admin_cap = test_utils::new_capability_without_restrictions( - trail.roles_mut(), + trail.access_mut(), &admin_cap, &string::utf8(b"CapAdmin"), &clock, @@ -139,7 +139,7 @@ fun test_role_based_permission_delegation() { let record_admin_perms = permission::record_admin_permissions(); trail - .roles_mut() + .access_mut() .create_role( &role_admin_cap, string::utf8(b"RecordAdmin"), @@ -150,8 +150,8 @@ fun test_role_based_permission_delegation() { ); // Verify RecordAdmin role was created successfully - assert!(trail.roles().size() == 4, 11); // Initial admin + RoleAdmin + CapAdmin + RecordAdmin - assert!(trail.roles().has_role(&string::utf8(b"RecordAdmin")), 12); + assert!(trail.access().size() == 4, 11); // Initial admin + RoleAdmin + CapAdmin + RecordAdmin + assert!(trail.access().has_role(&string::utf8(b"RecordAdmin")), 12); cleanup_capability_trail_and_clock(&scenario, role_admin_cap, trail, clock); }; @@ -165,7 +165,7 @@ fun test_role_based_permission_delegation() { assert!(cap_admin_cap.role() == string::utf8(b"CapAdmin"), 13); let record_admin_cap = test_utils::new_capability_without_restrictions( - trail.roles_mut(), + trail.access_mut(), &cap_admin_cap, &string::utf8(b"RecordAdmin"), &clock, @@ -241,12 +241,12 @@ fun test_delete_role_success() { let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); // Verify initial state - only Admin role exists - assert!(trail.roles().size() == 1, 0); + assert!(trail.access().size() == 1, 0); // Create a role to delete let perms = permission::from_vec(vector[permission::add_record()]); trail - .roles_mut() + .access_mut() .create_role( &admin_cap, string::utf8(b"RoleToDelete"), @@ -257,12 +257,12 @@ fun test_delete_role_success() { ); // Verify the role was created - assert!(trail.roles().size() == 2, 1); - assert!(trail.roles().has_role(&string::utf8(b"RoleToDelete")), 2); + assert!(trail.access().size() == 2, 1); + assert!(trail.access().has_role(&string::utf8(b"RoleToDelete")), 2); // Delete the role trail - .roles_mut() + .access_mut() .delete_role( &admin_cap, &string::utf8(b"RoleToDelete"), @@ -271,8 +271,8 @@ fun test_delete_role_success() { ); // Verify the role was deleted - assert!(trail.roles().size() == 1, 3); - assert!(!trail.roles().has_role(&string::utf8(b"RoleToDelete")), 4); + assert!(trail.access().size() == 1, 3); + assert!(!trail.access().has_role(&string::utf8(b"RoleToDelete")), 4); cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); }; @@ -313,7 +313,7 @@ fun test_create_role_permission_denied() { // Create role WITHOUT add_roles permission let perms = permission::from_vec(vector[permission::add_record()]); trail - .roles_mut() + .access_mut() .create_role( &admin_cap, string::utf8(b"NoRolesPerm"), @@ -324,7 +324,7 @@ fun test_create_role_permission_denied() { ); let user_cap = test_utils::new_capability_without_restrictions( - trail.roles_mut(), + trail.access_mut(), &admin_cap, &string::utf8(b"NoRolesPerm"), &clock, @@ -344,7 +344,7 @@ fun test_create_role_permission_denied() { // This should fail - no add_roles permission trail - .roles_mut() + .access_mut() .create_role( &user_cap, string::utf8(b"NewRole"), @@ -391,7 +391,7 @@ fun test_delete_role_permission_denied() { // Create a role to delete let perms = permission::from_vec(vector[permission::add_record()]); trail - .roles_mut() + .access_mut() .create_role( &admin_cap, string::utf8(b"RoleToDelete"), @@ -404,7 +404,7 @@ fun test_delete_role_permission_denied() { // Create role WITHOUT delete_roles permission let no_delete_perms = permission::from_vec(vector[permission::add_record()]); trail - .roles_mut() + .access_mut() .create_role( &admin_cap, string::utf8(b"NoDeleteRolePerm"), @@ -415,7 +415,7 @@ fun test_delete_role_permission_denied() { ); let user_cap = test_utils::new_capability_without_restrictions( - trail.roles_mut(), + trail.access_mut(), &admin_cap, &string::utf8(b"NoDeleteRolePerm"), &clock, @@ -433,7 +433,7 @@ fun test_delete_role_permission_denied() { // This should fail - no delete_roles permission trail - .roles_mut() + .access_mut() .delete_role(&user_cap, &string::utf8(b"RoleToDelete"), &clock, ts::ctx(&mut scenario)); cleanup_capability_trail_and_clock(&scenario, user_cap, trail, clock); @@ -473,7 +473,7 @@ fun test_update_role_permissions_permission_denied() { // Create a role to update let perms = permission::from_vec(vector[permission::add_record()]); trail - .roles_mut() + .access_mut() .create_role( &admin_cap, string::utf8(b"RoleToUpdate"), @@ -486,7 +486,7 @@ fun test_update_role_permissions_permission_denied() { // Create role WITHOUT update_roles permission let no_update_perms = permission::from_vec(vector[permission::add_record()]); trail - .roles_mut() + .access_mut() .create_role( &admin_cap, string::utf8(b"NoUpdateRolePerm"), @@ -497,7 +497,7 @@ fun test_update_role_permissions_permission_denied() { ); let user_cap = test_utils::new_capability_without_restrictions( - trail.roles_mut(), + trail.access_mut(), &admin_cap, &string::utf8(b"NoUpdateRolePerm"), &clock, @@ -517,7 +517,7 @@ fun test_update_role_permissions_permission_denied() { // This should fail - no update_roles permission trail - .roles_mut() + .access_mut() .update_role( &user_cap, &string::utf8(b"RoleToUpdate"), @@ -559,7 +559,7 @@ fun test_get_role_permissions_nonexistent() { let trail = ts::take_shared>(&scenario); // This should fail - role doesn't exist - let _perms = trail.roles().get_role_permissions(&string::utf8(b"NonExistentRole")); + let _perms = trail.access().get_role_permissions(&string::utf8(b"NonExistentRole")); ts::return_shared(trail); }; @@ -594,7 +594,7 @@ fun test_update_role_permissions_success() { // Create a role with add_record permission let initial_perms = permission::from_vec(vector[permission::add_record()]); trail - .roles_mut() + .access_mut() .create_role( &admin_cap, string::utf8(b"TestRole"), @@ -605,14 +605,14 @@ fun test_update_role_permissions_success() { ); // Verify the role was created with add_record permission - let perms = trail.roles().get_role_permissions(&string::utf8(b"TestRole")); + let perms = trail.access().get_role_permissions(&string::utf8(b"TestRole")); assert!(perms.contains(&permission::add_record()), 0); assert!(!perms.contains(&permission::delete_record()), 1); // Update the role to have delete_record permission instead let new_perms = permission::from_vec(vector[permission::delete_record()]); trail - .roles_mut() + .access_mut() .update_role( &admin_cap, &string::utf8(b"TestRole"), @@ -623,7 +623,7 @@ fun test_update_role_permissions_success() { ); // Verify the permissions were updated - let updated_perms = trail.roles().get_role_permissions(&string::utf8(b"TestRole")); + let updated_perms = trail.access().get_role_permissions(&string::utf8(b"TestRole")); assert!(!updated_perms.contains(&permission::add_record()), 2); assert!(updated_perms.contains(&permission::delete_record()), 3); @@ -662,7 +662,7 @@ fun test_update_role_permissions_nonexistent() { // This should fail - role doesn't exist trail - .roles_mut() + .access_mut() .update_role( &admin_cap, &string::utf8(b"NonExistentRole"), diff --git a/audit-trail-rs/src/core/roles/mod.rs b/audit-trail-rs/src/core/access/mod.rs similarity index 98% rename from audit-trail-rs/src/core/roles/mod.rs rename to audit-trail-rs/src/core/access/mod.rs index b92d7067..2568339b 100644 --- a/audit-trail-rs/src/core/roles/mod.rs +++ b/audit-trail-rs/src/core/access/mod.rs @@ -19,12 +19,12 @@ pub use transactions::{ }; #[derive(Debug, Clone)] -pub struct TrailRoles<'a, C> { +pub struct TrailAccess<'a, C> { pub(crate) client: &'a C, pub(crate) trail_id: ObjectID, } -impl<'a, C> TrailRoles<'a, C> { +impl<'a, C> TrailAccess<'a, C> { pub(crate) fn new(client: &'a C, trail_id: ObjectID) -> Self { Self { client, trail_id } } diff --git a/audit-trail-rs/src/core/roles/operations.rs b/audit-trail-rs/src/core/access/operations.rs similarity index 99% rename from audit-trail-rs/src/core/roles/operations.rs rename to audit-trail-rs/src/core/access/operations.rs index 2eac878e..9f8935eb 100644 --- a/audit-trail-rs/src/core/roles/operations.rs +++ b/audit-trail-rs/src/core/access/operations.rs @@ -10,9 +10,9 @@ use crate::core::types::{CapabilityIssueOptions, Permission, PermissionSet}; use crate::core::{operations, utils}; use crate::error::Error; -pub(super) struct RolesOps; +pub(super) struct AccessOps; -impl RolesOps { +impl AccessOps { pub(super) async fn create_role( client: &C, trail_id: ObjectID, diff --git a/audit-trail-rs/src/core/roles/transactions.rs b/audit-trail-rs/src/core/access/transactions.rs similarity index 96% rename from audit-trail-rs/src/core/roles/transactions.rs rename to audit-trail-rs/src/core/access/transactions.rs index 3bfc2ead..ecc12597 100644 --- a/audit-trail-rs/src/core/roles/transactions.rs +++ b/audit-trail-rs/src/core/access/transactions.rs @@ -10,7 +10,7 @@ use product_common::core_client::CoreClientReadOnly; use product_common::transaction::transaction_builder::Transaction; use tokio::sync::OnceCell; -use super::operations::RolesOps; +use super::operations::AccessOps; use crate::core::types::{ CapabilityDestroyed, CapabilityIssueOptions, CapabilityIssued, CapabilityRevoked, Event, PermissionSet, RoleCreated, RoleRemoved, RoleUpdated, @@ -43,7 +43,7 @@ impl CreateRole { where C: CoreClientReadOnly + OptionalSync, { - RolesOps::create_role( + AccessOps::create_role( client, self.trail_id, self.owner, @@ -117,7 +117,7 @@ impl UpdateRole { where C: CoreClientReadOnly + OptionalSync, { - RolesOps::update_role( + AccessOps::update_role( client, self.trail_id, self.owner, @@ -189,7 +189,7 @@ impl DeleteRole { where C: CoreClientReadOnly + OptionalSync, { - RolesOps::delete_role(client, self.trail_id, self.owner, self.name.clone()).await + AccessOps::delete_role(client, self.trail_id, self.owner, self.name.clone()).await } } @@ -256,7 +256,7 @@ impl IssueCapability { where C: CoreClientReadOnly + OptionalSync, { - RolesOps::issue_capability( + AccessOps::issue_capability( client, self.trail_id, self.owner, @@ -328,7 +328,7 @@ impl RevokeCapability { where C: CoreClientReadOnly + OptionalSync, { - RolesOps::revoke_capability(client, self.trail_id, self.owner, self.capability_id).await + AccessOps::revoke_capability(client, self.trail_id, self.owner, self.capability_id).await } } @@ -393,7 +393,7 @@ impl DestroyCapability { where C: CoreClientReadOnly + OptionalSync, { - RolesOps::destroy_capability(client, self.trail_id, self.owner, self.capability_id).await + AccessOps::destroy_capability(client, self.trail_id, self.owner, self.capability_id).await } } @@ -458,7 +458,7 @@ impl DestroyInitialAdminCapability { where C: CoreClientReadOnly + OptionalSync, { - RolesOps::destroy_initial_admin_capability(client, self.trail_id, self.capability_id).await + AccessOps::destroy_initial_admin_capability(client, self.trail_id, self.capability_id).await } } @@ -525,7 +525,7 @@ impl RevokeInitialAdminCapability { where C: CoreClientReadOnly + OptionalSync, { - RolesOps::revoke_initial_admin_capability(client, self.trail_id, self.owner, self.capability_id).await + AccessOps::revoke_initial_admin_capability(client, self.trail_id, self.owner, self.capability_id).await } } diff --git a/audit-trail-rs/src/core/mod.rs b/audit-trail-rs/src/core/mod.rs index 61bb2078..c4b2bfdb 100644 --- a/audit-trail-rs/src/core/mod.rs +++ b/audit-trail-rs/src/core/mod.rs @@ -3,12 +3,12 @@ //! Core types and builders for audit trails. +pub mod access; pub mod builder; pub mod create; pub mod locking; pub(crate) mod operations; pub mod records; -pub mod roles; pub mod trail; pub mod types; pub(crate) mod utils; diff --git a/audit-trail-rs/src/core/trail.rs b/audit-trail-rs/src/core/trail.rs index e3ffa157..aa3aaece 100644 --- a/audit-trail-rs/src/core/trail.rs +++ b/audit-trail-rs/src/core/trail.rs @@ -9,9 +9,9 @@ use product_common::transaction::transaction_builder::TransactionBuilder; use secret_storage::Signer; use serde::de::DeserializeOwned; +use crate::core::access::TrailAccess; use crate::core::locking::TrailLocking; use crate::core::records::TrailRecords; -use crate::core::roles::TrailRoles; use crate::core::types::{Data, OnChainAuditTrail}; use crate::error::Error; @@ -92,7 +92,7 @@ impl<'a, C> AuditTrailHandle<'a, C> { TrailLocking::new(self.client, self.trail_id) } - pub fn roles(&self) -> TrailRoles<'a, C> { - TrailRoles::new(self.client, self.trail_id) + pub fn access(&self) -> TrailAccess<'a, C> { + TrailAccess::new(self.client, self.trail_id) } } diff --git a/audit-trail-rs/src/package.rs b/audit-trail-rs/src/package.rs index f9acec41..f21f5d58 100644 --- a/audit-trail-rs/src/package.rs +++ b/audit-trail-rs/src/package.rs @@ -33,6 +33,7 @@ static AUDIT_TRAIL_PACKAGE_REGISTRY: LazyLock> = LazyLoc /// Hardcoded TfComponents package ID used for timelock constructors. /// /// Update this value after publishing TfComponents. +/// TODO:Replac this with real value const TF_COMPONENTS_PACKAGE_ID: &str = "0x5deb1782f8f078d7d85640099466c6513bee3ac261555fb06cb0bbe1f838ab17"; /// Returns a read lock to the package registry. diff --git a/audit-trail-rs/tests/e2e/roles.rs b/audit-trail-rs/tests/e2e/access.rs similarity index 81% rename from audit-trail-rs/tests/e2e/roles.rs rename to audit-trail-rs/tests/e2e/access.rs index 76bf9030..f3d5f0f7 100644 --- a/audit-trail-rs/tests/e2e/roles.rs +++ b/audit-trail-rs/tests/e2e/access.rs @@ -12,7 +12,7 @@ use crate::client::get_funded_test_client; #[tokio::test] async fn create_role_then_issue_capability_default_options() -> anyhow::Result<()> { let client = get_funded_test_client().await?; - let trail_id = client.create_test_trail(Data::text("roles-e2e")).await?; + let trail_id = client.create_test_trail(Data::text("access-e2e")).await?; let role_name = "auditor"; client @@ -35,15 +35,15 @@ async fn create_role_then_issue_capability_default_options() -> anyhow::Result<( #[tokio::test] async fn update_role_permissions_then_issue_capability() -> anyhow::Result<()> { let client = get_funded_test_client().await?; - let trail_id = client.create_test_trail(Data::text("roles-e2e")).await?; - let roles = client.trail(trail_id).roles(); + let trail_id = client.create_test_trail(Data::text("access-e2e")).await?; + let access = client.trail(trail_id).access(); let role_name = "editor"; client .create_role(trail_id, role_name, vec![Permission::AddRecord]) .await?; - let updated = roles + let updated = access .for_role(role_name) .update_permissions(PermissionSet { permissions: HashSet::from([Permission::AddRecord, Permission::DeleteRecord]), @@ -66,14 +66,14 @@ async fn update_role_permissions_then_issue_capability() -> anyhow::Result<()> { #[tokio::test] async fn delete_role_prevents_new_capability_issuance() -> anyhow::Result<()> { let client = get_funded_test_client().await?; - let trail_id = client.create_test_trail(Data::text("roles-e2e")).await?; - let roles = client.trail(trail_id).roles(); + let trail_id = client.create_test_trail(Data::text("access-e2e")).await?; + let access = client.trail(trail_id).access(); let role_name = "to-delete"; client .create_role(trail_id, role_name, vec![Permission::AddRecord]) .await?; - let deleted = roles + let deleted = access .for_role(role_name) .delete() .build_and_execute(&client) @@ -82,7 +82,7 @@ async fn delete_role_prevents_new_capability_issuance() -> anyhow::Result<()> { assert_eq!(deleted.trail_id, trail_id); assert_eq!(deleted.role, role_name.to_string()); - let issue_tx = roles + let issue_tx = access .for_role(role_name) .issue_capability(CapabilityIssueOptions::default()); let issue_after_delete = issue_tx.build_and_execute(&client).await; @@ -96,7 +96,7 @@ async fn delete_role_prevents_new_capability_issuance() -> anyhow::Result<()> { #[tokio::test] async fn issue_capability_with_constraints() -> anyhow::Result<()> { let client = get_funded_test_client().await?; - let trail_id = client.create_test_trail(Data::text("roles-e2e")).await?; + let trail_id = client.create_test_trail(Data::text("access-e2e")).await?; let role_name = "reviewer"; client @@ -124,8 +124,8 @@ async fn issue_capability_with_constraints() -> anyhow::Result<()> { #[tokio::test] async fn revoke_capability_emits_expected_event_data() -> anyhow::Result<()> { let client = get_funded_test_client().await?; - let trail_id = client.create_test_trail(Data::text("roles-e2e")).await?; - let roles = client.trail(trail_id).roles(); + let trail_id = client.create_test_trail(Data::text("access-e2e")).await?; + let access = client.trail(trail_id).access(); let role_name = "revoker"; client @@ -136,7 +136,7 @@ async fn revoke_capability_emits_expected_event_data() -> anyhow::Result<()> { .issue_cap(trail_id, role_name, CapabilityIssueOptions::default()) .await?; - let revoked = roles + let revoked = access .revoke_capability(issued.capability_id) .build_and_execute(&client) .await? @@ -150,8 +150,8 @@ async fn revoke_capability_emits_expected_event_data() -> anyhow::Result<()> { #[tokio::test] async fn destroy_capability_emits_expected_event_data() -> anyhow::Result<()> { let client = get_funded_test_client().await?; - let trail_id = client.create_test_trail(Data::text("roles-e2e")).await?; - let roles = client.trail(trail_id).roles(); + let trail_id = client.create_test_trail(Data::text("access-e2e")).await?; + let access = client.trail(trail_id).access(); let role_name = "destroyer"; client @@ -162,7 +162,7 @@ async fn destroy_capability_emits_expected_event_data() -> anyhow::Result<()> { .issue_cap(trail_id, role_name, CapabilityIssueOptions::default()) .await?; - let destroyed = roles + let destroyed = access .destroy_capability(issued.capability_id) .build_and_execute(&client) .await? @@ -177,13 +177,13 @@ async fn destroy_capability_emits_expected_event_data() -> anyhow::Result<()> { #[tokio::test] async fn destroy_initial_admin_capability_emits_expected_event() -> anyhow::Result<()> { let client = get_funded_test_client().await?; - let trail_id = client.create_test_trail(Data::text("roles-e2e")).await?; - let roles = client.trail(trail_id).roles(); + let trail_id = client.create_test_trail(Data::text("access-e2e")).await?; + let access = client.trail(trail_id).access(); let admin_cap_ref = client.get_cap(client.sender_address(), trail_id).await?; let admin_cap_id = admin_cap_ref.0; - let destroyed = roles + let destroyed = access .destroy_initial_admin_capability(admin_cap_id) .build_and_execute(&client) .await? @@ -198,15 +198,15 @@ async fn destroy_initial_admin_capability_emits_expected_event() -> anyhow::Resu #[tokio::test] async fn revoke_initial_admin_capability_emits_expected_event() -> anyhow::Result<()> { let client = get_funded_test_client().await?; - let trail_id = client.create_test_trail(Data::text("roles-e2e")).await?; + let trail_id = client.create_test_trail(Data::text("access-e2e")).await?; // Issue a second admin capability so we can use the original to revoke it let second_admin = client .issue_cap(trail_id, "Admin", CapabilityIssueOptions::default()) .await?; - let roles = client.trail(trail_id).roles(); - let revoked = roles + let access = client.trail(trail_id).access(); + let revoked = access .revoke_initial_admin_capability(second_admin.capability_id) .build_and_execute(&client) .await? @@ -221,13 +221,13 @@ async fn revoke_initial_admin_capability_emits_expected_event() -> anyhow::Resul #[tokio::test] async fn regular_destroy_rejects_initial_admin_capability() -> anyhow::Result<()> { let client = get_funded_test_client().await?; - let trail_id = client.create_test_trail(Data::text("roles-e2e")).await?; - let roles = client.trail(trail_id).roles(); + let trail_id = client.create_test_trail(Data::text("access-e2e")).await?; + let access = client.trail(trail_id).access(); let admin_cap_ref = client.get_cap(client.sender_address(), trail_id).await?; let admin_cap_id = admin_cap_ref.0; - let result = roles.destroy_capability(admin_cap_id).build_and_execute(&client).await; + let result = access.destroy_capability(admin_cap_id).build_and_execute(&client).await; assert!( result.is_err(), @@ -240,13 +240,13 @@ async fn regular_destroy_rejects_initial_admin_capability() -> anyhow::Result<() #[tokio::test] async fn regular_revoke_rejects_initial_admin_capability() -> anyhow::Result<()> { let client = get_funded_test_client().await?; - let trail_id = client.create_test_trail(Data::text("roles-e2e")).await?; - let roles = client.trail(trail_id).roles(); + let trail_id = client.create_test_trail(Data::text("access-e2e")).await?; + let access = client.trail(trail_id).access(); let admin_cap_ref = client.get_cap(client.sender_address(), trail_id).await?; let admin_cap_id = admin_cap_ref.0; - let result = roles.revoke_capability(admin_cap_id).build_and_execute(&client).await; + let result = access.revoke_capability(admin_cap_id).build_and_execute(&client).await; assert!( result.is_err(), diff --git a/audit-trail-rs/tests/e2e/client.rs b/audit-trail-rs/tests/e2e/client.rs index 4a5cb775..264cf38f 100644 --- a/audit-trail-rs/tests/e2e/client.rs +++ b/audit-trail-rs/tests/e2e/client.rs @@ -105,7 +105,7 @@ impl TestClient { ) -> anyhow::Result { let created = self .trail(trail_id) - .roles() + .access() .for_role(role_name) .create(PermissionSet { permissions: permissions.into_iter().collect::>(), @@ -125,7 +125,7 @@ impl TestClient { ) -> anyhow::Result { let issued = self .trail(trail_id) - .roles() + .access() .for_role(role_name) .issue_capability(options) .build_and_execute(self) diff --git a/audit-trail-rs/tests/e2e/main.rs b/audit-trail-rs/tests/e2e/main.rs index 7c076225..a5aa07f1 100644 --- a/audit-trail-rs/tests/e2e/main.rs +++ b/audit-trail-rs/tests/e2e/main.rs @@ -6,5 +6,5 @@ // mod client; // mod locking; // mod records; -// mod roles; +// mod access; // mod trail; From 366c9785795a0c785fb75acec5b380839fa4e272 Mon Sep 17 00:00:00 2001 From: Yasir Date: Tue, 17 Mar 2026 21:55:37 +0300 Subject: [PATCH 076/189] Align audit trail WASM bindings with Rust API --- audit-trail-rs/src/core/types/role_map.rs | 1 + audit-trail-rs/src/error.rs | 5 + bindings/wasm/audit_trails_wasm/src/client.rs | 41 +- .../audit_trails_wasm/src/client_read_only.rs | 13 +- bindings/wasm/audit_trails_wasm/src/lib.rs | 21 +- bindings/wasm/audit_trails_wasm/src/trail.rs | 402 +++++++++++-- .../audit_trails_wasm/src/trail_handle.rs | 74 --- .../src/trail_handle/access.rs | 175 ++++++ .../src/trail_handle/locking.rs | 94 +++ .../audit_trails_wasm/src/trail_handle/mod.rs | 115 ++++ .../records.rs} | 94 ++- bindings/wasm/audit_trails_wasm/src/types.rs | 546 +++++++++++++++++- 12 files changed, 1388 insertions(+), 193 deletions(-) delete mode 100644 bindings/wasm/audit_trails_wasm/src/trail_handle.rs create mode 100644 bindings/wasm/audit_trails_wasm/src/trail_handle/access.rs create mode 100644 bindings/wasm/audit_trails_wasm/src/trail_handle/locking.rs create mode 100644 bindings/wasm/audit_trails_wasm/src/trail_handle/mod.rs rename bindings/wasm/audit_trails_wasm/src/{trail_records.rs => trail_handle/records.rs} (54%) diff --git a/audit-trail-rs/src/core/types/role_map.rs b/audit-trail-rs/src/core/types/role_map.rs index acb8aa3d..7dcd424e 100644 --- a/audit-trail-rs/src/core/types/role_map.rs +++ b/audit-trail-rs/src/core/types/role_map.rs @@ -12,6 +12,7 @@ use serde::{Deserialize, Serialize}; use super::permission::Permission; use crate::core::utils::{deserialize_vec_map, deserialize_vec_set}; +use crate::package; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RoleMap { diff --git a/audit-trail-rs/src/error.rs b/audit-trail-rs/src/error.rs index 1eb39585..79f75834 100644 --- a/audit-trail-rs/src/error.rs +++ b/audit-trail-rs/src/error.rs @@ -41,3 +41,8 @@ pub enum Error { #[error("unexpected transaction response: {0}")] TransactionUnexpectedResponse(String), } + +#[cfg(target_arch = "wasm32")] +use product_common::impl_wasm_error_from; +#[cfg(target_arch = "wasm32")] +impl_wasm_error_from!(Error); diff --git a/bindings/wasm/audit_trails_wasm/src/client.rs b/bindings/wasm/audit_trails_wasm/src/client.rs index 7ba632b7..2b68b081 100644 --- a/bindings/wasm/audit_trails_wasm/src/client.rs +++ b/bindings/wasm/audit_trails_wasm/src/client.rs @@ -1,8 +1,11 @@ // Copyright 2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +use anyhow::anyhow; +use audit_trails::{AuditTrailClient, AuditTrailClientReadOnly}; use iota_interaction_ts::bindings::{WasmIotaClient, WasmPublicKey, WasmTransactionSigner}; -use iota_interaction_ts::wasm_error::Result; +use iota_interaction_ts::wasm_error::{wasm_error, Result, WasmResult}; +use product_common::bindings::utils::parse_wasm_object_id; use product_common::bindings::WasmObjectID; use product_common::core_client::{CoreClient, CoreClientReadOnly}; use wasm_bindgen::prelude::*; @@ -10,11 +13,10 @@ use wasm_bindgen::prelude::*; use crate::builder::WasmAuditTrailBuilder; use crate::client_read_only::WasmAuditTrailClientReadOnly; use crate::trail_handle::WasmAuditTrailHandle; -use crate::audit_trails_wasm_result; #[derive(Clone)] #[wasm_bindgen(js_name = AuditTrailClient)] -pub struct WasmAuditTrailClient(pub(crate) audit_trails::AuditTrailClient); +pub struct WasmAuditTrailClient(pub(crate) AuditTrailClient); #[wasm_bindgen(js_class = AuditTrailClient)] impl WasmAuditTrailClient { @@ -23,7 +25,26 @@ impl WasmAuditTrailClient { client: WasmAuditTrailClientReadOnly, signer: WasmTransactionSigner, ) -> Result { - let client = audit_trails_wasm_result(audit_trails::AuditTrailClient::new(client.0, signer).await)?; + let client = AuditTrailClient::new(client.0, signer).await.wasm_result()?; + Ok(Self(client)) + } + + #[wasm_bindgen(js_name = createFromIotaClient)] + pub async fn create_from_iota_client( + iota_client: WasmIotaClient, + signer: WasmTransactionSigner, + package_id: Option, + ) -> Result { + let read_only = if let Some(package_id) = package_id { + let package_id = parse_wasm_object_id(&package_id)?; + AuditTrailClientReadOnly::new_with_pkg_id(iota_client, package_id) + .await + .wasm_result()? + } else { + AuditTrailClientReadOnly::new(iota_client).await.wasm_result()? + }; + + let client = AuditTrailClient::new(read_only, signer).await.wasm_result()?; Ok(Self(client)) } @@ -66,6 +87,16 @@ impl WasmAuditTrailClient { self.0.signer().clone() } + #[wasm_bindgen(js_name = withSigner)] + pub async fn with_signer(self, signer: WasmTransactionSigner) -> Result { + let client = self + .0 + .with_signer(signer) + .await + .map_err(|err| wasm_error(anyhow!(err.to_string())))?; + Ok(Self(client)) + } + #[wasm_bindgen(js_name = readOnly)] pub fn read_only(&self) -> WasmAuditTrailClientReadOnly { WasmAuditTrailClientReadOnly(self.0.read_only().clone()) @@ -77,7 +108,7 @@ impl WasmAuditTrailClient { } pub fn trail(&self, trail_id: WasmObjectID) -> Result { - let trail_id = product_common::bindings::utils::parse_wasm_object_id(&trail_id)?; + let trail_id = parse_wasm_object_id(&trail_id)?; Ok(WasmAuditTrailHandle::from_full(self.0.clone(), trail_id)) } } diff --git a/bindings/wasm/audit_trails_wasm/src/client_read_only.rs b/bindings/wasm/audit_trails_wasm/src/client_read_only.rs index 9c185078..c94d1925 100644 --- a/bindings/wasm/audit_trails_wasm/src/client_read_only.rs +++ b/bindings/wasm/audit_trails_wasm/src/client_read_only.rs @@ -1,25 +1,25 @@ // Copyright 2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +use audit_trails::AuditTrailClientReadOnly; use iota_interaction_ts::bindings::WasmIotaClient; -use iota_interaction_ts::wasm_error::Result; +use iota_interaction_ts::wasm_error::{Result, WasmResult}; use product_common::bindings::utils::parse_wasm_object_id; use product_common::bindings::WasmObjectID; use product_common::core_client::CoreClientReadOnly; use wasm_bindgen::prelude::*; use crate::trail_handle::WasmAuditTrailHandle; -use crate::audit_trails_wasm_result; #[derive(Clone)] #[wasm_bindgen(js_name = AuditTrailClientReadOnly)] -pub struct WasmAuditTrailClientReadOnly(pub(crate) audit_trails::AuditTrailClientReadOnly); +pub struct WasmAuditTrailClientReadOnly(pub(crate) AuditTrailClientReadOnly); #[wasm_bindgen(js_class = AuditTrailClientReadOnly)] impl WasmAuditTrailClientReadOnly { #[wasm_bindgen(js_name = create)] pub async fn new(iota_client: WasmIotaClient) -> Result { - let client = audit_trails_wasm_result(audit_trails::AuditTrailClientReadOnly::new(iota_client).await)?; + let client = AuditTrailClientReadOnly::new(iota_client).await.wasm_result()?; Ok(Self(client)) } @@ -29,8 +29,9 @@ impl WasmAuditTrailClientReadOnly { package_id: WasmObjectID, ) -> Result { let package_id = parse_wasm_object_id(&package_id)?; - let client = - audit_trails_wasm_result(audit_trails::AuditTrailClientReadOnly::new_with_pkg_id(iota_client, package_id).await)?; + let client = AuditTrailClientReadOnly::new_with_pkg_id(iota_client, package_id) + .await + .wasm_result()?; Ok(Self(client)) } diff --git a/bindings/wasm/audit_trails_wasm/src/lib.rs b/bindings/wasm/audit_trails_wasm/src/lib.rs index 12350067..fa475db9 100644 --- a/bindings/wasm/audit_trails_wasm/src/lib.rs +++ b/bindings/wasm/audit_trails_wasm/src/lib.rs @@ -7,18 +7,14 @@ #![allow(clippy::unused_unit)] #![allow(clippy::await_holding_refcell_ref)] -use std::borrow::Cow; - -use iota_interaction_ts::wasm_error::{Result, WasmError}; -use wasm_bindgen::JsValue; use wasm_bindgen::prelude::*; +use wasm_bindgen::JsValue; -mod trail; pub(crate) mod builder; pub(crate) mod client; pub(crate) mod client_read_only; +mod trail; pub(crate) mod trail_handle; -pub(crate) mod trail_records; pub(crate) mod types; pub use product_common::bindings::*; @@ -39,16 +35,3 @@ import { CoreClientReadOnly } from '../lib/index'; "#; - -pub(crate) fn audit_trails_wasm_error(error: audit_trails::error::Error) -> JsValue { - JsValue::from(WasmError { - name: Cow::Borrowed("audit_trails::Error"), - message: Cow::Owned(error.to_string()), - }) -} - -pub(crate) fn audit_trails_wasm_result( - result: std::result::Result, -) -> Result { - result.map_err(audit_trails_wasm_error) -} diff --git a/bindings/wasm/audit_trails_wasm/src/trail.rs b/bindings/wasm/audit_trails_wasm/src/trail.rs index a87e0881..ce1c7593 100644 --- a/bindings/wasm/audit_trails_wasm/src/trail.rs +++ b/bindings/wasm/audit_trails_wasm/src/trail.rs @@ -1,21 +1,33 @@ // Copyright 2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +use audit_trails::core::access::{ + CreateRole, DeleteRole, DestroyCapability, DestroyInitialAdminCapability, IssueCapability, RevokeCapability, + RevokeInitialAdminCapability, UpdateRole, +}; use audit_trails::core::create::{CreateTrail, TrailCreated}; +use audit_trails::core::locking::{ + UpdateDeleteRecordWindow, UpdateDeleteTrailLock, UpdateLockingConfig, UpdateWriteLock, +}; use audit_trails::core::records::{AddRecord, DeleteRecord, DeleteRecordsBatch}; -use audit_trails::core::trail::UpdateMetadata; -use audit_trails::core::types::{OnChainAuditTrail, RecordAdded, RecordDeleted}; +use audit_trails::core::trail::{DeleteAuditTrail, Migrate, UpdateMetadata}; +use audit_trails::core::types::{ + AuditTrailDeleted, CapabilityDestroyed, CapabilityIssued, CapabilityRevoked, OnChainAuditTrail, RecordAdded, + RecordDeleted, RoleCreated, RoleRemoved, RoleUpdated, +}; use iota_interaction_ts::bindings::{WasmIotaTransactionBlockEffects, WasmIotaTransactionBlockEvents}; use iota_interaction_ts::core_client::WasmCoreClientReadOnly; use iota_interaction_ts::wasm_error::{Result, WasmResult}; -use js_sys::Object; use product_common::bindings::core_client::WasmManagedCoreClientReadOnly; -use product_common::transaction::transaction_builder::Transaction; +use product_common::bindings::utils::{apply_with_events, build_programmable_transaction}; use wasm_bindgen::prelude::*; use crate::builder::WasmAuditTrailBuilder; -use crate::types::{WasmEmpty, WasmImmutableMetadata, WasmLockingConfig}; -use crate::audit_trails_wasm_result; +use crate::types::{ + WasmAuditTrailDeleted, WasmCapabilityDestroyed, WasmCapabilityIssued, WasmCapabilityRevoked, WasmEmpty, + WasmImmutableMetadata, WasmLinkedTable, WasmLockingConfig, WasmRoleCreated, WasmRoleMap, WasmRoleRemoved, + WasmRoleUpdated, +}; #[wasm_bindgen(js_name = OnChainAuditTrail, inspectable)] #[derive(Clone)] @@ -52,6 +64,16 @@ impl WasmOnChainAuditTrail { self.0.locking_config.clone().into() } + #[wasm_bindgen(getter)] + pub fn records(&self) -> WasmLinkedTable { + self.0.records.clone().into() + } + + #[wasm_bindgen(getter)] + pub fn roles(&self) -> WasmRoleMap { + self.0.roles.clone().into() + } + #[wasm_bindgen(js_name = immutableMetadata, getter)] pub fn immutable_metadata(&self) -> Option { self.0.immutable_metadata.clone().map(Into::into) @@ -81,52 +103,11 @@ async fn apply_trail_created( client: &WasmCoreClientReadOnly, ) -> Result { let managed_client = WasmManagedCoreClientReadOnly::from_wasm(client)?; - let mut effects = wasm_effects.clone().into(); - let mut events = wasm_events.clone().into(); - let created = tx.apply_with_events(&mut effects, &mut events, &managed_client).await; - - let rem_wasm_effects = WasmIotaTransactionBlockEffects::from(&effects); - Object::assign(wasm_effects, &rem_wasm_effects); - let rem_wasm_events = WasmIotaTransactionBlockEvents::from(&events); - Object::assign(wasm_events, &rem_wasm_events); - - let created: TrailCreated = audit_trails_wasm_result(created)?; - let trail = audit_trails_wasm_result(created.fetch_audit_trail(&managed_client).await)?; + let created: TrailCreated = apply_with_events(tx, wasm_effects, wasm_events, client).await?; + let trail = created.fetch_audit_trail(&managed_client).await.wasm_result()?; Ok(trail.into()) } -async fn build_audit_trail_transaction(tx: &T, client: &WasmCoreClientReadOnly) -> Result> -where - T: Transaction, -{ - let managed_client = WasmManagedCoreClientReadOnly::from_wasm(client)?; - let pt = audit_trails_wasm_result(tx.build_programmable_transaction(&managed_client).await)?; - bcs::to_bytes(&pt).wasm_result() -} - -async fn apply_audit_trail_with_events( - tx: T, - wasm_effects: &WasmIotaTransactionBlockEffects, - wasm_events: &WasmIotaTransactionBlockEvents, - client: &WasmCoreClientReadOnly, -) -> Result -where - T: Transaction, - O: From<::Output>, -{ - let managed_client = WasmManagedCoreClientReadOnly::from_wasm(client)?; - let mut effects = wasm_effects.clone().into(); - let mut events = wasm_events.clone().into(); - let output = tx.apply_with_events(&mut effects, &mut events, &managed_client).await; - - let rem_wasm_effects = WasmIotaTransactionBlockEffects::from(&effects); - Object::assign(wasm_effects, &rem_wasm_effects); - let rem_wasm_events = WasmIotaTransactionBlockEvents::from(&events); - Object::assign(wasm_events, &rem_wasm_events); - - audit_trails_wasm_result(output).map(Into::into) -} - #[wasm_bindgen(js_name = CreateTrail, inspectable)] pub struct WasmCreateTrail(pub(crate) CreateTrail); @@ -139,7 +120,7 @@ impl WasmCreateTrail { #[wasm_bindgen(js_name = buildProgrammableTransaction)] pub async fn build_programmable_transaction(&self, client: &WasmCoreClientReadOnly) -> Result> { - build_audit_trail_transaction(&self.0, client).await + build_programmable_transaction(&self.0, client).await } #[wasm_bindgen(js_name = applyWithEvents)] @@ -160,7 +141,92 @@ pub struct WasmUpdateMetadata(pub(crate) UpdateMetadata); impl WasmUpdateMetadata { #[wasm_bindgen(js_name = buildProgrammableTransaction)] pub async fn build_programmable_transaction(&self, client: &WasmCoreClientReadOnly) -> Result> { - build_audit_trail_transaction(&self.0, client).await + build_programmable_transaction(&self.0, client).await + } + + #[wasm_bindgen(js_name = applyWithEvents)] + pub async fn apply_with_events( + self, + wasm_effects: &WasmIotaTransactionBlockEffects, + wasm_events: &WasmIotaTransactionBlockEvents, + client: &WasmCoreClientReadOnly, + ) -> Result { + apply_with_events(self.0, wasm_effects, wasm_events, client).await + } +} + +#[wasm_bindgen(js_name = Migrate, inspectable)] +pub struct WasmMigrate(pub(crate) Migrate); + +#[wasm_bindgen(js_class = Migrate)] +impl WasmMigrate { + #[wasm_bindgen(js_name = buildProgrammableTransaction)] + pub async fn build_programmable_transaction(&self, client: &WasmCoreClientReadOnly) -> Result> { + build_programmable_transaction(&self.0, client).await + } + + #[wasm_bindgen(js_name = applyWithEvents)] + pub async fn apply_with_events( + self, + wasm_effects: &WasmIotaTransactionBlockEffects, + wasm_events: &WasmIotaTransactionBlockEvents, + client: &WasmCoreClientReadOnly, + ) -> Result { + apply_with_events(self.0, wasm_effects, wasm_events, client).await + } +} + +#[wasm_bindgen(js_name = DeleteAuditTrail, inspectable)] +pub struct WasmDeleteAuditTrail(pub(crate) DeleteAuditTrail); + +#[wasm_bindgen(js_class = DeleteAuditTrail)] +impl WasmDeleteAuditTrail { + #[wasm_bindgen(js_name = buildProgrammableTransaction)] + pub async fn build_programmable_transaction(&self, client: &WasmCoreClientReadOnly) -> Result> { + build_programmable_transaction(&self.0, client).await + } + + #[wasm_bindgen(js_name = applyWithEvents)] + pub async fn apply_with_events( + self, + wasm_effects: &WasmIotaTransactionBlockEffects, + wasm_events: &WasmIotaTransactionBlockEvents, + client: &WasmCoreClientReadOnly, + ) -> Result { + let event: AuditTrailDeleted = apply_with_events(self.0, wasm_effects, wasm_events, client).await?; + Ok(event.into()) + } +} + +#[wasm_bindgen(js_name = UpdateLockingConfig, inspectable)] +pub struct WasmUpdateLockingConfig(pub(crate) UpdateLockingConfig); + +#[wasm_bindgen(js_class = UpdateLockingConfig)] +impl WasmUpdateLockingConfig { + #[wasm_bindgen(js_name = buildProgrammableTransaction)] + pub async fn build_programmable_transaction(&self, client: &WasmCoreClientReadOnly) -> Result> { + build_programmable_transaction(&self.0, client).await + } + + #[wasm_bindgen(js_name = applyWithEvents)] + pub async fn apply_with_events( + self, + wasm_effects: &WasmIotaTransactionBlockEffects, + wasm_events: &WasmIotaTransactionBlockEvents, + client: &WasmCoreClientReadOnly, + ) -> Result { + apply_with_events(self.0, wasm_effects, wasm_events, client).await + } +} + +#[wasm_bindgen(js_name = UpdateDeleteRecordWindow, inspectable)] +pub struct WasmUpdateDeleteRecordWindow(pub(crate) UpdateDeleteRecordWindow); + +#[wasm_bindgen(js_class = UpdateDeleteRecordWindow)] +impl WasmUpdateDeleteRecordWindow { + #[wasm_bindgen(js_name = buildProgrammableTransaction)] + pub async fn build_programmable_transaction(&self, client: &WasmCoreClientReadOnly) -> Result> { + build_programmable_transaction(&self.0, client).await } #[wasm_bindgen(js_name = applyWithEvents)] @@ -170,7 +236,225 @@ impl WasmUpdateMetadata { wasm_events: &WasmIotaTransactionBlockEvents, client: &WasmCoreClientReadOnly, ) -> Result { - apply_audit_trail_with_events(self.0, wasm_effects, wasm_events, client).await + apply_with_events(self.0, wasm_effects, wasm_events, client).await + } +} + +#[wasm_bindgen(js_name = UpdateDeleteTrailLock, inspectable)] +pub struct WasmUpdateDeleteTrailLock(pub(crate) UpdateDeleteTrailLock); + +#[wasm_bindgen(js_class = UpdateDeleteTrailLock)] +impl WasmUpdateDeleteTrailLock { + #[wasm_bindgen(js_name = buildProgrammableTransaction)] + pub async fn build_programmable_transaction(&self, client: &WasmCoreClientReadOnly) -> Result> { + build_programmable_transaction(&self.0, client).await + } + + #[wasm_bindgen(js_name = applyWithEvents)] + pub async fn apply_with_events( + self, + wasm_effects: &WasmIotaTransactionBlockEffects, + wasm_events: &WasmIotaTransactionBlockEvents, + client: &WasmCoreClientReadOnly, + ) -> Result { + apply_with_events(self.0, wasm_effects, wasm_events, client).await + } +} + +#[wasm_bindgen(js_name = UpdateWriteLock, inspectable)] +pub struct WasmUpdateWriteLock(pub(crate) UpdateWriteLock); + +#[wasm_bindgen(js_class = UpdateWriteLock)] +impl WasmUpdateWriteLock { + #[wasm_bindgen(js_name = buildProgrammableTransaction)] + pub async fn build_programmable_transaction(&self, client: &WasmCoreClientReadOnly) -> Result> { + build_programmable_transaction(&self.0, client).await + } + + #[wasm_bindgen(js_name = applyWithEvents)] + pub async fn apply_with_events( + self, + wasm_effects: &WasmIotaTransactionBlockEffects, + wasm_events: &WasmIotaTransactionBlockEvents, + client: &WasmCoreClientReadOnly, + ) -> Result { + apply_with_events(self.0, wasm_effects, wasm_events, client).await + } +} + +#[wasm_bindgen(js_name = CreateRole, inspectable)] +pub struct WasmCreateRole(pub(crate) CreateRole); + +#[wasm_bindgen(js_class = CreateRole)] +impl WasmCreateRole { + #[wasm_bindgen(js_name = buildProgrammableTransaction)] + pub async fn build_programmable_transaction(&self, client: &WasmCoreClientReadOnly) -> Result> { + build_programmable_transaction(&self.0, client).await + } + + #[wasm_bindgen(js_name = applyWithEvents)] + pub async fn apply_with_events( + self, + wasm_effects: &WasmIotaTransactionBlockEffects, + wasm_events: &WasmIotaTransactionBlockEvents, + client: &WasmCoreClientReadOnly, + ) -> Result { + let event: RoleCreated = apply_with_events(self.0, wasm_effects, wasm_events, client).await?; + Ok(event.into()) + } +} + +#[wasm_bindgen(js_name = UpdateRole, inspectable)] +pub struct WasmUpdateRole(pub(crate) UpdateRole); + +#[wasm_bindgen(js_class = UpdateRole)] +impl WasmUpdateRole { + #[wasm_bindgen(js_name = buildProgrammableTransaction)] + pub async fn build_programmable_transaction(&self, client: &WasmCoreClientReadOnly) -> Result> { + build_programmable_transaction(&self.0, client).await + } + + #[wasm_bindgen(js_name = applyWithEvents)] + pub async fn apply_with_events( + self, + wasm_effects: &WasmIotaTransactionBlockEffects, + wasm_events: &WasmIotaTransactionBlockEvents, + client: &WasmCoreClientReadOnly, + ) -> Result { + let event: RoleUpdated = apply_with_events(self.0, wasm_effects, wasm_events, client).await?; + Ok(event.into()) + } +} + +#[wasm_bindgen(js_name = DeleteRole, inspectable)] +pub struct WasmDeleteRole(pub(crate) DeleteRole); + +#[wasm_bindgen(js_class = DeleteRole)] +impl WasmDeleteRole { + #[wasm_bindgen(js_name = buildProgrammableTransaction)] + pub async fn build_programmable_transaction(&self, client: &WasmCoreClientReadOnly) -> Result> { + build_programmable_transaction(&self.0, client).await + } + + #[wasm_bindgen(js_name = applyWithEvents)] + pub async fn apply_with_events( + self, + wasm_effects: &WasmIotaTransactionBlockEffects, + wasm_events: &WasmIotaTransactionBlockEvents, + client: &WasmCoreClientReadOnly, + ) -> Result { + let event: RoleRemoved = apply_with_events(self.0, wasm_effects, wasm_events, client).await?; + Ok(event.into()) + } +} + +#[wasm_bindgen(js_name = IssueCapability, inspectable)] +pub struct WasmIssueCapability(pub(crate) IssueCapability); + +#[wasm_bindgen(js_class = IssueCapability)] +impl WasmIssueCapability { + #[wasm_bindgen(js_name = buildProgrammableTransaction)] + pub async fn build_programmable_transaction(&self, client: &WasmCoreClientReadOnly) -> Result> { + build_programmable_transaction(&self.0, client).await + } + + #[wasm_bindgen(js_name = applyWithEvents)] + pub async fn apply_with_events( + self, + wasm_effects: &WasmIotaTransactionBlockEffects, + wasm_events: &WasmIotaTransactionBlockEvents, + client: &WasmCoreClientReadOnly, + ) -> Result { + let event: CapabilityIssued = apply_with_events(self.0, wasm_effects, wasm_events, client).await?; + Ok(event.into()) + } +} + +#[wasm_bindgen(js_name = RevokeCapability, inspectable)] +pub struct WasmRevokeCapability(pub(crate) RevokeCapability); + +#[wasm_bindgen(js_class = RevokeCapability)] +impl WasmRevokeCapability { + #[wasm_bindgen(js_name = buildProgrammableTransaction)] + pub async fn build_programmable_transaction(&self, client: &WasmCoreClientReadOnly) -> Result> { + build_programmable_transaction(&self.0, client).await + } + + #[wasm_bindgen(js_name = applyWithEvents)] + pub async fn apply_with_events( + self, + wasm_effects: &WasmIotaTransactionBlockEffects, + wasm_events: &WasmIotaTransactionBlockEvents, + client: &WasmCoreClientReadOnly, + ) -> Result { + let event: CapabilityRevoked = apply_with_events(self.0, wasm_effects, wasm_events, client).await?; + Ok(event.into()) + } +} + +#[wasm_bindgen(js_name = DestroyCapability, inspectable)] +pub struct WasmDestroyCapability(pub(crate) DestroyCapability); + +#[wasm_bindgen(js_class = DestroyCapability)] +impl WasmDestroyCapability { + #[wasm_bindgen(js_name = buildProgrammableTransaction)] + pub async fn build_programmable_transaction(&self, client: &WasmCoreClientReadOnly) -> Result> { + build_programmable_transaction(&self.0, client).await + } + + #[wasm_bindgen(js_name = applyWithEvents)] + pub async fn apply_with_events( + self, + wasm_effects: &WasmIotaTransactionBlockEffects, + wasm_events: &WasmIotaTransactionBlockEvents, + client: &WasmCoreClientReadOnly, + ) -> Result { + let event: CapabilityDestroyed = apply_with_events(self.0, wasm_effects, wasm_events, client).await?; + Ok(event.into()) + } +} + +#[wasm_bindgen(js_name = DestroyInitialAdminCapability, inspectable)] +pub struct WasmDestroyInitialAdminCapability(pub(crate) DestroyInitialAdminCapability); + +#[wasm_bindgen(js_class = DestroyInitialAdminCapability)] +impl WasmDestroyInitialAdminCapability { + #[wasm_bindgen(js_name = buildProgrammableTransaction)] + pub async fn build_programmable_transaction(&self, client: &WasmCoreClientReadOnly) -> Result> { + build_programmable_transaction(&self.0, client).await + } + + #[wasm_bindgen(js_name = applyWithEvents)] + pub async fn apply_with_events( + self, + wasm_effects: &WasmIotaTransactionBlockEffects, + wasm_events: &WasmIotaTransactionBlockEvents, + client: &WasmCoreClientReadOnly, + ) -> Result { + let event: CapabilityDestroyed = apply_with_events(self.0, wasm_effects, wasm_events, client).await?; + Ok(event.into()) + } +} + +#[wasm_bindgen(js_name = RevokeInitialAdminCapability, inspectable)] +pub struct WasmRevokeInitialAdminCapability(pub(crate) RevokeInitialAdminCapability); + +#[wasm_bindgen(js_class = RevokeInitialAdminCapability)] +impl WasmRevokeInitialAdminCapability { + #[wasm_bindgen(js_name = buildProgrammableTransaction)] + pub async fn build_programmable_transaction(&self, client: &WasmCoreClientReadOnly) -> Result> { + build_programmable_transaction(&self.0, client).await + } + + #[wasm_bindgen(js_name = applyWithEvents)] + pub async fn apply_with_events( + self, + wasm_effects: &WasmIotaTransactionBlockEffects, + wasm_events: &WasmIotaTransactionBlockEvents, + client: &WasmCoreClientReadOnly, + ) -> Result { + let event: CapabilityRevoked = apply_with_events(self.0, wasm_effects, wasm_events, client).await?; + Ok(event.into()) } } @@ -181,7 +465,7 @@ pub struct WasmAddRecord(pub(crate) AddRecord); impl WasmAddRecord { #[wasm_bindgen(js_name = buildProgrammableTransaction)] pub async fn build_programmable_transaction(&self, client: &WasmCoreClientReadOnly) -> Result> { - build_audit_trail_transaction(&self.0, client).await + build_programmable_transaction(&self.0, client).await } #[wasm_bindgen(js_name = applyWithEvents)] @@ -191,8 +475,7 @@ impl WasmAddRecord { wasm_events: &WasmIotaTransactionBlockEvents, client: &WasmCoreClientReadOnly, ) -> Result { - let added: RecordAdded = - apply_audit_trail_with_events(self.0, wasm_effects, wasm_events, client).await?; + let added: RecordAdded = apply_with_events(self.0, wasm_effects, wasm_events, client).await?; Ok(added.sequence_number) } } @@ -204,7 +487,7 @@ pub struct WasmDeleteRecord(pub(crate) DeleteRecord); impl WasmDeleteRecord { #[wasm_bindgen(js_name = buildProgrammableTransaction)] pub async fn build_programmable_transaction(&self, client: &WasmCoreClientReadOnly) -> Result> { - build_audit_trail_transaction(&self.0, client).await + build_programmable_transaction(&self.0, client).await } #[wasm_bindgen(js_name = applyWithEvents)] @@ -214,8 +497,7 @@ impl WasmDeleteRecord { wasm_events: &WasmIotaTransactionBlockEvents, client: &WasmCoreClientReadOnly, ) -> Result { - let deleted: RecordDeleted = - apply_audit_trail_with_events(self.0, wasm_effects, wasm_events, client).await?; + let deleted: RecordDeleted = apply_with_events(self.0, wasm_effects, wasm_events, client).await?; Ok(deleted.sequence_number) } } @@ -227,7 +509,7 @@ pub struct WasmDeleteRecordsBatch(pub(crate) DeleteRecordsBatch); impl WasmDeleteRecordsBatch { #[wasm_bindgen(js_name = buildProgrammableTransaction)] pub async fn build_programmable_transaction(&self, client: &WasmCoreClientReadOnly) -> Result> { - build_audit_trail_transaction(&self.0, client).await + build_programmable_transaction(&self.0, client).await } #[wasm_bindgen(js_name = applyWithEvents)] @@ -237,6 +519,6 @@ impl WasmDeleteRecordsBatch { wasm_events: &WasmIotaTransactionBlockEvents, client: &WasmCoreClientReadOnly, ) -> Result { - apply_audit_trail_with_events(self.0, wasm_effects, wasm_events, client).await + apply_with_events(self.0, wasm_effects, wasm_events, client).await } } diff --git a/bindings/wasm/audit_trails_wasm/src/trail_handle.rs b/bindings/wasm/audit_trails_wasm/src/trail_handle.rs deleted file mode 100644 index f78eaed6..00000000 --- a/bindings/wasm/audit_trails_wasm/src/trail_handle.rs +++ /dev/null @@ -1,74 +0,0 @@ -// Copyright 2026 IOTA Stiftung -// SPDX-License-Identifier: Apache-2.0 - -use anyhow::anyhow; -use iota_interaction::types::base_types::ObjectID; -use iota_interaction_ts::bindings::WasmTransactionSigner; -use iota_interaction_ts::wasm_error::{wasm_error, Result}; -use product_common::bindings::transaction::WasmTransactionBuilder; -use product_common::bindings::utils::into_transaction_builder; -use wasm_bindgen::prelude::*; - -use crate::trail::{WasmOnChainAuditTrail, WasmUpdateMetadata}; -use crate::trail_records::WasmTrailRecords; -use crate::audit_trails_wasm_result; - -#[derive(Clone)] -#[wasm_bindgen(js_name = AuditTrailHandle, inspectable)] -pub struct WasmAuditTrailHandle { - pub(crate) read_only: audit_trails::AuditTrailClientReadOnly, - pub(crate) full: Option>, - pub(crate) trail_id: ObjectID, -} - -impl WasmAuditTrailHandle { - pub(crate) fn from_read_only(read_only: audit_trails::AuditTrailClientReadOnly, trail_id: ObjectID) -> Self { - Self { - read_only, - full: None, - trail_id, - } - } - - pub(crate) fn from_full(full: audit_trails::AuditTrailClient, trail_id: ObjectID) -> Self { - Self { - read_only: full.read_only().clone(), - full: Some(full), - trail_id, - } - } - - fn full_client(&self) -> Result<&audit_trails::AuditTrailClient> { - self.full.as_ref().ok_or_else(|| { - wasm_error(anyhow!( - "AuditTrailHandle was created from a read-only client; this operation requires AuditTrailClient" - )) - }) - } -} - -#[wasm_bindgen(js_class = AuditTrailHandle)] -impl WasmAuditTrailHandle { - pub async fn get(&self) -> Result { - let trail = audit_trails_wasm_result(self.read_only.trail(self.trail_id).get().await)?; - Ok(trail.into()) - } - - #[wasm_bindgen(js_name = updateMetadata, unchecked_return_type = "TransactionBuilder")] - pub fn update_metadata(&self, metadata: Option) -> Result { - let tx = self - .full_client()? - .trail(self.trail_id) - .update_metadata(metadata) - .into_inner(); - Ok(into_transaction_builder(WasmUpdateMetadata(tx))) - } - - pub fn records(&self) -> WasmTrailRecords { - WasmTrailRecords { - read_only: self.read_only.clone(), - full: self.full.clone(), - trail_id: self.trail_id, - } - } -} diff --git a/bindings/wasm/audit_trails_wasm/src/trail_handle/access.rs b/bindings/wasm/audit_trails_wasm/src/trail_handle/access.rs new file mode 100644 index 00000000..d6fab80d --- /dev/null +++ b/bindings/wasm/audit_trails_wasm/src/trail_handle/access.rs @@ -0,0 +1,175 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use anyhow::anyhow; +use audit_trails::AuditTrailClient; +use iota_interaction::types::base_types::ObjectID; +use iota_interaction_ts::bindings::WasmTransactionSigner; +use iota_interaction_ts::wasm_error::{wasm_error, Result}; +use product_common::bindings::transaction::WasmTransactionBuilder; +use product_common::bindings::utils::{into_transaction_builder, parse_wasm_object_id}; +use product_common::bindings::WasmObjectID; +use wasm_bindgen::prelude::*; + +use crate::trail::{ + WasmCreateRole, WasmDeleteRole, WasmDestroyCapability, WasmDestroyInitialAdminCapability, WasmIssueCapability, + WasmRevokeCapability, WasmRevokeInitialAdminCapability, WasmUpdateRole, +}; +use crate::types::{WasmCapabilityIssueOptions, WasmPermissionSet}; + +#[derive(Clone)] +#[wasm_bindgen(js_name = TrailAccess, inspectable)] +pub struct WasmTrailAccess { + pub(crate) full: Option>, + pub(crate) trail_id: ObjectID, +} + +impl WasmTrailAccess { + /// Returns the writable client for access-control operations. + /// + /// Throws when this wrapper was created from `AuditTrailClientReadOnly`. + fn require_write(&self) -> Result<&AuditTrailClient> { + self.full.as_ref().ok_or_else(|| { + wasm_error(anyhow!( + "TrailAccess was created from a read-only client; this operation requires AuditTrailClient" + )) + }) + } +} + +#[wasm_bindgen(js_class = TrailAccess)] +impl WasmTrailAccess { + #[wasm_bindgen(js_name = forRole)] + pub fn for_role(&self, name: String) -> WasmRoleHandle { + WasmRoleHandle { + full: self.full.clone(), + trail_id: self.trail_id, + name, + } + } + + #[wasm_bindgen(js_name = revokeCapability, unchecked_return_type = "TransactionBuilder")] + pub fn revoke_capability(&self, capability_id: WasmObjectID) -> Result { + let capability_id = parse_wasm_object_id(&capability_id)?; + let tx = self + .require_write()? + .trail(self.trail_id) + .access() + .revoke_capability(capability_id) + .into_inner(); + Ok(into_transaction_builder(WasmRevokeCapability(tx))) + } + + #[wasm_bindgen(js_name = destroyCapability, unchecked_return_type = "TransactionBuilder")] + pub fn destroy_capability(&self, capability_id: WasmObjectID) -> Result { + let capability_id = parse_wasm_object_id(&capability_id)?; + let tx = self + .require_write()? + .trail(self.trail_id) + .access() + .destroy_capability(capability_id) + .into_inner(); + Ok(into_transaction_builder(WasmDestroyCapability(tx))) + } + + #[wasm_bindgen(js_name = destroyInitialAdminCapability, unchecked_return_type = "TransactionBuilder")] + pub fn destroy_initial_admin_capability(&self, capability_id: WasmObjectID) -> Result { + let capability_id = parse_wasm_object_id(&capability_id)?; + let tx = self + .require_write()? + .trail(self.trail_id) + .access() + .destroy_initial_admin_capability(capability_id) + .into_inner(); + Ok(into_transaction_builder(WasmDestroyInitialAdminCapability(tx))) + } + + #[wasm_bindgen(js_name = revokeInitialAdminCapability, unchecked_return_type = "TransactionBuilder")] + pub fn revoke_initial_admin_capability(&self, capability_id: WasmObjectID) -> Result { + let capability_id = parse_wasm_object_id(&capability_id)?; + let tx = self + .require_write()? + .trail(self.trail_id) + .access() + .revoke_initial_admin_capability(capability_id) + .into_inner(); + Ok(into_transaction_builder(WasmRevokeInitialAdminCapability(tx))) + } +} + +#[derive(Clone)] +#[wasm_bindgen(js_name = RoleHandle, inspectable)] +pub struct WasmRoleHandle { + pub(crate) full: Option>, + pub(crate) trail_id: ObjectID, + pub(crate) name: String, +} + +impl WasmRoleHandle { + /// Returns the writable client for role mutations. + /// + /// Throws when this wrapper was created from `AuditTrailClientReadOnly`. + fn require_write(&self) -> Result<&AuditTrailClient> { + self.full.as_ref().ok_or_else(|| { + wasm_error(anyhow!( + "RoleHandle was created from a read-only client; this operation requires AuditTrailClient" + )) + }) + } +} + +#[wasm_bindgen(js_class = RoleHandle)] +impl WasmRoleHandle { + #[wasm_bindgen(getter)] + pub fn name(&self) -> String { + self.name.clone() + } + + #[wasm_bindgen(unchecked_return_type = "TransactionBuilder")] + pub fn create(&self, permissions: WasmPermissionSet) -> Result { + let tx = self + .require_write()? + .trail(self.trail_id) + .access() + .for_role(self.name.clone()) + .create(permissions.into()) + .into_inner(); + Ok(into_transaction_builder(WasmCreateRole(tx))) + } + + #[wasm_bindgen(js_name = issueCapability, unchecked_return_type = "TransactionBuilder")] + pub fn issue_capability(&self, options: WasmCapabilityIssueOptions) -> Result { + let tx = self + .require_write()? + .trail(self.trail_id) + .access() + .for_role(self.name.clone()) + .issue_capability(options.into()) + .into_inner(); + Ok(into_transaction_builder(WasmIssueCapability(tx))) + } + + #[wasm_bindgen(js_name = updatePermissions, unchecked_return_type = "TransactionBuilder")] + pub fn update_permissions(&self, permissions: WasmPermissionSet) -> Result { + let tx = self + .require_write()? + .trail(self.trail_id) + .access() + .for_role(self.name.clone()) + .update_permissions(permissions.into()) + .into_inner(); + Ok(into_transaction_builder(WasmUpdateRole(tx))) + } + + #[wasm_bindgen(unchecked_return_type = "TransactionBuilder")] + pub fn delete(&self) -> Result { + let tx = self + .require_write()? + .trail(self.trail_id) + .access() + .for_role(self.name.clone()) + .delete() + .into_inner(); + Ok(into_transaction_builder(WasmDeleteRole(tx))) + } +} diff --git a/bindings/wasm/audit_trails_wasm/src/trail_handle/locking.rs b/bindings/wasm/audit_trails_wasm/src/trail_handle/locking.rs new file mode 100644 index 00000000..c82fef9a --- /dev/null +++ b/bindings/wasm/audit_trails_wasm/src/trail_handle/locking.rs @@ -0,0 +1,94 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use anyhow::anyhow; +use audit_trails::{AuditTrailClient, AuditTrailClientReadOnly}; +use iota_interaction::types::base_types::ObjectID; +use iota_interaction_ts::bindings::WasmTransactionSigner; +use iota_interaction_ts::wasm_error::{wasm_error, Result, WasmResult}; +use product_common::bindings::transaction::WasmTransactionBuilder; +use product_common::bindings::utils::into_transaction_builder; +use wasm_bindgen::prelude::*; + +use crate::trail::{ + WasmUpdateDeleteRecordWindow, WasmUpdateDeleteTrailLock, WasmUpdateLockingConfig, WasmUpdateWriteLock, +}; +use crate::types::{WasmLockingConfig, WasmLockingWindow, WasmTimeLock}; + +#[derive(Clone)] +#[wasm_bindgen(js_name = TrailLocking, inspectable)] +pub struct WasmTrailLocking { + pub(crate) read_only: AuditTrailClientReadOnly, + pub(crate) full: Option>, + pub(crate) trail_id: ObjectID, +} + +impl WasmTrailLocking { + /// Returns the writable client for locking updates. + /// + /// Throws when this wrapper was created from `AuditTrailClientReadOnly`. + fn require_write(&self) -> Result<&AuditTrailClient> { + self.full.as_ref().ok_or_else(|| { + wasm_error(anyhow!( + "TrailLocking was created from a read-only client; this operation requires AuditTrailClient" + )) + }) + } +} + +#[wasm_bindgen(js_class = TrailLocking)] +impl WasmTrailLocking { + #[wasm_bindgen(unchecked_return_type = "TransactionBuilder")] + pub fn update(&self, config: WasmLockingConfig) -> Result { + let tx = self + .require_write()? + .trail(self.trail_id) + .locking() + .update(config.into()) + .into_inner(); + Ok(into_transaction_builder(WasmUpdateLockingConfig(tx))) + } + + #[wasm_bindgen(js_name = updateDeleteRecordWindow, unchecked_return_type = "TransactionBuilder")] + pub fn update_delete_record_window(&self, window: WasmLockingWindow) -> Result { + let tx = self + .require_write()? + .trail(self.trail_id) + .locking() + .update_delete_record_window(window.into()) + .into_inner(); + Ok(into_transaction_builder(WasmUpdateDeleteRecordWindow(tx))) + } + + #[wasm_bindgen(js_name = updateDeleteTrailLock, unchecked_return_type = "TransactionBuilder")] + pub fn update_delete_trail_lock(&self, lock: WasmTimeLock) -> Result { + let tx = self + .require_write()? + .trail(self.trail_id) + .locking() + .update_delete_trail_lock(lock.into()) + .into_inner(); + Ok(into_transaction_builder(WasmUpdateDeleteTrailLock(tx))) + } + + #[wasm_bindgen(js_name = updateWriteLock, unchecked_return_type = "TransactionBuilder")] + pub fn update_write_lock(&self, lock: WasmTimeLock) -> Result { + let tx = self + .require_write()? + .trail(self.trail_id) + .locking() + .update_write_lock(lock.into()) + .into_inner(); + Ok(into_transaction_builder(WasmUpdateWriteLock(tx))) + } + + #[wasm_bindgen(js_name = isRecordLocked)] + pub async fn is_record_locked(&self, sequence_number: u64) -> Result { + self.read_only + .trail(self.trail_id) + .locking() + .is_record_locked(sequence_number) + .await + .wasm_result() + } +} diff --git a/bindings/wasm/audit_trails_wasm/src/trail_handle/mod.rs b/bindings/wasm/audit_trails_wasm/src/trail_handle/mod.rs new file mode 100644 index 00000000..ed934517 --- /dev/null +++ b/bindings/wasm/audit_trails_wasm/src/trail_handle/mod.rs @@ -0,0 +1,115 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +mod access; +mod locking; +mod records; + +use anyhow::anyhow; +use audit_trails::{AuditTrailClient, AuditTrailClientReadOnly}; +use iota_interaction::types::base_types::ObjectID; +use iota_interaction_ts::bindings::WasmTransactionSigner; +use iota_interaction_ts::wasm_error::{wasm_error, Result, WasmResult}; +use product_common::bindings::transaction::WasmTransactionBuilder; +use product_common::bindings::utils::into_transaction_builder; +use wasm_bindgen::prelude::*; + +use crate::trail::{WasmDeleteAuditTrail, WasmMigrate, WasmOnChainAuditTrail, WasmUpdateMetadata}; + +pub(crate) use access::WasmTrailAccess; +pub(crate) use locking::WasmTrailLocking; +pub(crate) use records::WasmTrailRecords; + +#[derive(Clone)] +#[wasm_bindgen(js_name = AuditTrailHandle, inspectable)] +pub struct WasmAuditTrailHandle { + pub(crate) read_only: AuditTrailClientReadOnly, + pub(crate) full: Option>, + pub(crate) trail_id: ObjectID, +} + +impl WasmAuditTrailHandle { + pub(crate) fn from_read_only(read_only: AuditTrailClientReadOnly, trail_id: ObjectID) -> Self { + Self { + read_only, + full: None, + trail_id, + } + } + + pub(crate) fn from_full(full: AuditTrailClient, trail_id: ObjectID) -> Self { + Self { + read_only: full.read_only().clone(), + full: Some(full), + trail_id, + } + } + + /// Returns the writable client when this handle came from `AuditTrailClient`. + /// + /// Throws when the handle was created from `AuditTrailClientReadOnly`. + fn require_write(&self) -> Result<&AuditTrailClient> { + self.full.as_ref().ok_or_else(|| { + wasm_error(anyhow!( + "AuditTrailHandle was created from a read-only client; this operation requires AuditTrailClient" + )) + }) + } +} + +#[wasm_bindgen(js_class = AuditTrailHandle)] +impl WasmAuditTrailHandle { + pub async fn get(&self) -> Result { + let trail = self.read_only.trail(self.trail_id).get().await.wasm_result()?; + Ok(trail.into()) + } + + #[wasm_bindgen(unchecked_return_type = "TransactionBuilder")] + pub fn migrate(&self) -> Result { + let tx = self.require_write()?.trail(self.trail_id).migrate().into_inner(); + Ok(into_transaction_builder(WasmMigrate(tx))) + } + + #[wasm_bindgen(js_name = deleteAuditTrail, unchecked_return_type = "TransactionBuilder")] + pub fn delete_audit_trail(&self) -> Result { + let tx = self + .require_write()? + .trail(self.trail_id) + .delete_audit_trail() + .into_inner(); + Ok(into_transaction_builder(WasmDeleteAuditTrail(tx))) + } + + #[wasm_bindgen(js_name = updateMetadata, unchecked_return_type = "TransactionBuilder")] + pub fn update_metadata(&self, metadata: Option) -> Result { + let tx = self + .require_write()? + .trail(self.trail_id) + .update_metadata(metadata) + .into_inner(); + Ok(into_transaction_builder(WasmUpdateMetadata(tx))) + } + + pub fn records(&self) -> WasmTrailRecords { + WasmTrailRecords { + read_only: self.read_only.clone(), + full: self.full.clone(), + trail_id: self.trail_id, + } + } + + pub fn access(&self) -> WasmTrailAccess { + WasmTrailAccess { + full: self.full.clone(), + trail_id: self.trail_id, + } + } + + pub fn locking(&self) -> WasmTrailLocking { + WasmTrailLocking { + read_only: self.read_only.clone(), + full: self.full.clone(), + trail_id: self.trail_id, + } + } +} diff --git a/bindings/wasm/audit_trails_wasm/src/trail_records.rs b/bindings/wasm/audit_trails_wasm/src/trail_handle/records.rs similarity index 54% rename from bindings/wasm/audit_trails_wasm/src/trail_records.rs rename to bindings/wasm/audit_trails_wasm/src/trail_handle/records.rs index d9239c26..72ba4dd2 100644 --- a/bindings/wasm/audit_trails_wasm/src/trail_records.rs +++ b/bindings/wasm/audit_trails_wasm/src/trail_handle/records.rs @@ -2,27 +2,31 @@ // SPDX-License-Identifier: Apache-2.0 use anyhow::anyhow; +use audit_trails::core::types::Data as AuditTrailData; +use audit_trails::{AuditTrailClient, AuditTrailClientReadOnly}; use iota_interaction::types::base_types::ObjectID; use iota_interaction_ts::bindings::WasmTransactionSigner; -use iota_interaction_ts::wasm_error::{wasm_error, Result}; +use iota_interaction_ts::wasm_error::{wasm_error, Result, WasmResult}; use product_common::bindings::transaction::WasmTransactionBuilder; use product_common::bindings::utils::into_transaction_builder; use wasm_bindgen::prelude::*; use crate::trail::{WasmAddRecord, WasmDeleteRecord, WasmDeleteRecordsBatch}; -use crate::types::{WasmPaginatedRecord, WasmRecord}; -use crate::audit_trails_wasm_result; +use crate::types::{WasmData, WasmEmpty, WasmPaginatedRecord, WasmRecord}; #[derive(Clone)] #[wasm_bindgen(js_name = TrailRecords, inspectable)] pub struct WasmTrailRecords { - pub(crate) read_only: audit_trails::AuditTrailClientReadOnly, - pub(crate) full: Option>, + pub(crate) read_only: AuditTrailClientReadOnly, + pub(crate) full: Option>, pub(crate) trail_id: ObjectID, } impl WasmTrailRecords { - fn full_client(&self) -> Result<&audit_trails::AuditTrailClient> { + /// Returns the writable client for record mutations. + /// + /// Throws when this wrapper was created from `AuditTrailClientReadOnly`. + fn require_write(&self) -> Result<&AuditTrailClient> { self.full.as_ref().ok_or_else(|| { wasm_error(anyhow!( "TrailRecords was created from a read-only client; this operation requires AuditTrailClient" @@ -34,59 +38,95 @@ impl WasmTrailRecords { #[wasm_bindgen(js_class = TrailRecords)] impl WasmTrailRecords { pub async fn get(&self, sequence_number: u64) -> Result { - let record = audit_trails_wasm_result( - self + let record = self .read_only .trail(self.trail_id) .records() .get(sequence_number) - .await, - )?; + .await + .wasm_result()?; Ok(record.into()) } #[wasm_bindgen(js_name = recordCount)] pub async fn record_count(&self) -> Result { - audit_trails_wasm_result(self.read_only.trail(self.trail_id).records().record_count().await) + self.read_only + .trail(self.trail_id) + .records() + .record_count() + .await + .wasm_result() } pub async fn list(&self) -> Result> { - let mut records: Vec<_> = - audit_trails_wasm_result(self.read_only.trail(self.trail_id).records().list().await)? - .into_iter() - .collect(); + let mut records: Vec<_> = self + .read_only + .trail(self.trail_id) + .records() + .list() + .await + .wasm_result()? + .into_iter() + .collect(); + records.sort_unstable_by_key(|(sequence_number, _)| *sequence_number); + Ok(records.into_iter().map(|(_, record)| record.into()).collect()) + } + + #[wasm_bindgen(js_name = listWithLimit)] + pub async fn list_with_limit(&self, max_entries: usize) -> Result> { + let mut records: Vec<_> = self + .read_only + .trail(self.trail_id) + .records() + .list_with_limit(max_entries) + .await + .wasm_result()? + .into_iter() + .collect(); records.sort_unstable_by_key(|(sequence_number, _)| *sequence_number); Ok(records.into_iter().map(|(_, record)| record.into()).collect()) } #[wasm_bindgen(js_name = listPage)] pub async fn list_page(&self, cursor: Option, limit: usize) -> Result { - let page = audit_trails_wasm_result(self.read_only.trail(self.trail_id).records().list_page(cursor, limit).await)?; + let page = self + .read_only + .trail(self.trail_id) + .records() + .list_page(cursor, limit) + .await + .wasm_result()?; Ok(page.into()) } + pub async fn correct(&self, replaces: Vec, data: WasmData, metadata: Option) -> Result { + self.require_write()? + .trail(self.trail_id) + .records() + .correct(replaces, data.into(), metadata) + .await + .wasm_result()?; + Ok(WasmEmpty) + } + #[wasm_bindgen(js_name = addString, unchecked_return_type = "TransactionBuilder")] pub fn add_string(&self, data: String, metadata: Option) -> Result { let tx = self - .full_client()? + .require_write()? .trail(self.trail_id) .records() - .add(audit_trails::core::types::Data::text(data), metadata) + .add(AuditTrailData::text(data), metadata) .into_inner(); Ok(into_transaction_builder(WasmAddRecord(tx))) } #[wasm_bindgen(js_name = addBytes, unchecked_return_type = "TransactionBuilder")] - pub fn add_bytes( - &self, - data: js_sys::Uint8Array, - metadata: Option, - ) -> Result { + pub fn add_bytes(&self, data: js_sys::Uint8Array, metadata: Option) -> Result { let tx = self - .full_client()? + .require_write()? .trail(self.trail_id) .records() - .add(audit_trails::core::types::Data::bytes(data.to_vec()), metadata) + .add(AuditTrailData::bytes(data.to_vec()), metadata) .into_inner(); Ok(into_transaction_builder(WasmAddRecord(tx))) } @@ -94,7 +134,7 @@ impl WasmTrailRecords { #[wasm_bindgen(unchecked_return_type = "TransactionBuilder")] pub fn delete(&self, sequence_number: u64) -> Result { let tx = self - .full_client()? + .require_write()? .trail(self.trail_id) .records() .delete(sequence_number) @@ -105,7 +145,7 @@ impl WasmTrailRecords { #[wasm_bindgen(js_name = deleteBatch, unchecked_return_type = "TransactionBuilder")] pub fn delete_batch(&self, limit: u64) -> Result { let tx = self - .full_client()? + .require_write()? .trail(self.trail_id) .records() .delete_records_batch(limit) diff --git a/bindings/wasm/audit_trails_wasm/src/types.rs b/bindings/wasm/audit_trails_wasm/src/types.rs index a2b0426e..99ca9684 100644 --- a/bindings/wasm/audit_trails_wasm/src/types.rs +++ b/bindings/wasm/audit_trails_wasm/src/types.rs @@ -1,11 +1,15 @@ // Copyright 2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use audit_trails::core::types::{ - Data, ImmutableMetadata, LockingConfig, LockingWindow, PaginatedRecord, Record, RecordCorrection, TimeLock, + AuditTrailCreated, AuditTrailDeleted, Capability, CapabilityAdminPermissions, CapabilityDestroyed, + CapabilityIssueOptions, CapabilityIssued, CapabilityRevoked, Data, ImmutableMetadata, LockingConfig, LockingWindow, + PaginatedRecord, Permission, PermissionSet, Record, RecordAdded, RecordCorrection, RecordDeleted, + RoleAdminPermissions, RoleCreated, RoleMap, RoleRemoved, RoleUpdated, TimeLock, }; +use iota_interaction::types::collection_types::LinkedTable; use js_sys::Uint8Array; use product_common::bindings::WasmIotaAddress; use serde::{Deserialize, Serialize}; @@ -73,6 +77,544 @@ impl From for Data { } } +fn permission_sort_key(permission: Permission) -> u8 { + match permission { + Permission::DeleteAuditTrail => 0, + Permission::DeleteAllRecords => 1, + Permission::AddRecord => 2, + Permission::DeleteRecord => 3, + Permission::CorrectRecord => 4, + Permission::UpdateLockingConfig => 5, + Permission::UpdateLockingConfigForDeleteRecord => 6, + Permission::UpdateLockingConfigForDeleteTrail => 7, + Permission::UpdateLockingConfigForWrite => 8, + Permission::AddRoles => 9, + Permission::UpdateRoles => 10, + Permission::DeleteRoles => 11, + Permission::AddCapabilities => 12, + Permission::RevokeCapabilities => 13, + Permission::UpdateMetadata => 14, + Permission::DeleteMetadata => 15, + Permission::Migrate => 16, + } +} + +fn sorted_permissions_from_set(permissions: HashSet) -> Vec { + let mut permissions: Vec<_> = permissions.into_iter().collect(); + permissions.sort_unstable_by_key(|permission| permission_sort_key(*permission)); + permissions.into_iter().map(Into::into).collect() +} + +fn sorted_object_ids(ids: HashSet) -> Vec { + let mut ids: Vec<_> = ids.into_iter().map(|id| id.to_string()).collect(); + ids.sort_unstable(); + ids +} + +fn sorted_role_entries(roles: HashMap>) -> Vec { + let mut roles: Vec<_> = roles + .into_iter() + .map(|(name, permissions)| WasmRolePermissionsEntry { + name, + permissions: sorted_permissions_from_set(permissions), + }) + .collect(); + roles.sort_unstable_by(|left, right| left.name.cmp(&right.name)); + roles +} + +#[wasm_bindgen(js_name = Permission)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum WasmPermission { + DeleteAuditTrail, + DeleteAllRecords, + AddRecord, + DeleteRecord, + CorrectRecord, + UpdateLockingConfig, + UpdateLockingConfigForDeleteRecord, + UpdateLockingConfigForDeleteTrail, + UpdateLockingConfigForWrite, + AddRoles, + UpdateRoles, + DeleteRoles, + AddCapabilities, + RevokeCapabilities, + UpdateMetadata, + DeleteMetadata, + Migrate, +} + +impl From for WasmPermission { + fn from(value: Permission) -> Self { + match value { + Permission::DeleteAuditTrail => Self::DeleteAuditTrail, + Permission::DeleteAllRecords => Self::DeleteAllRecords, + Permission::AddRecord => Self::AddRecord, + Permission::DeleteRecord => Self::DeleteRecord, + Permission::CorrectRecord => Self::CorrectRecord, + Permission::UpdateLockingConfig => Self::UpdateLockingConfig, + Permission::UpdateLockingConfigForDeleteRecord => Self::UpdateLockingConfigForDeleteRecord, + Permission::UpdateLockingConfigForDeleteTrail => Self::UpdateLockingConfigForDeleteTrail, + Permission::UpdateLockingConfigForWrite => Self::UpdateLockingConfigForWrite, + Permission::AddRoles => Self::AddRoles, + Permission::UpdateRoles => Self::UpdateRoles, + Permission::DeleteRoles => Self::DeleteRoles, + Permission::AddCapabilities => Self::AddCapabilities, + Permission::RevokeCapabilities => Self::RevokeCapabilities, + Permission::UpdateMetadata => Self::UpdateMetadata, + Permission::DeleteMetadata => Self::DeleteMetadata, + Permission::Migrate => Self::Migrate, + } + } +} + +impl From for Permission { + fn from(value: WasmPermission) -> Self { + match value { + WasmPermission::DeleteAuditTrail => Self::DeleteAuditTrail, + WasmPermission::DeleteAllRecords => Self::DeleteAllRecords, + WasmPermission::AddRecord => Self::AddRecord, + WasmPermission::DeleteRecord => Self::DeleteRecord, + WasmPermission::CorrectRecord => Self::CorrectRecord, + WasmPermission::UpdateLockingConfig => Self::UpdateLockingConfig, + WasmPermission::UpdateLockingConfigForDeleteRecord => Self::UpdateLockingConfigForDeleteRecord, + WasmPermission::UpdateLockingConfigForDeleteTrail => Self::UpdateLockingConfigForDeleteTrail, + WasmPermission::UpdateLockingConfigForWrite => Self::UpdateLockingConfigForWrite, + WasmPermission::AddRoles => Self::AddRoles, + WasmPermission::UpdateRoles => Self::UpdateRoles, + WasmPermission::DeleteRoles => Self::DeleteRoles, + WasmPermission::AddCapabilities => Self::AddCapabilities, + WasmPermission::RevokeCapabilities => Self::RevokeCapabilities, + WasmPermission::UpdateMetadata => Self::UpdateMetadata, + WasmPermission::DeleteMetadata => Self::DeleteMetadata, + WasmPermission::Migrate => Self::Migrate, + } + } +} + +#[wasm_bindgen(js_name = PermissionSet, getter_with_clone, inspectable)] +#[derive(Clone)] +pub struct WasmPermissionSet { + pub permissions: Vec, +} + +#[wasm_bindgen(js_class = PermissionSet)] +impl WasmPermissionSet { + #[wasm_bindgen(constructor)] + pub fn new(permissions: Vec) -> Self { + Self { permissions } + } + + #[wasm_bindgen(js_name = adminPermissions)] + pub fn admin_permissions() -> Self { + PermissionSet::admin_permissions().into() + } + + #[wasm_bindgen(js_name = recordAdminPermissions)] + pub fn record_admin_permissions() -> Self { + PermissionSet::record_admin_permissions().into() + } + + #[wasm_bindgen(js_name = lockingAdminPermissions)] + pub fn locking_admin_permissions() -> Self { + PermissionSet::locking_admin_permissions().into() + } + + #[wasm_bindgen(js_name = roleAdminPermissions)] + pub fn role_admin_permissions() -> Self { + PermissionSet::role_admin_permissions().into() + } + + #[wasm_bindgen(js_name = capAdminPermissions)] + pub fn cap_admin_permissions() -> Self { + PermissionSet::cap_admin_permissions().into() + } + + #[wasm_bindgen(js_name = metadataAdminPermissions)] + pub fn metadata_admin_permissions() -> Self { + PermissionSet::metadata_admin_permissions().into() + } +} + +impl From for WasmPermissionSet { + fn from(value: PermissionSet) -> Self { + Self { + permissions: sorted_permissions_from_set(value.permissions), + } + } +} + +impl From for PermissionSet { + fn from(value: WasmPermissionSet) -> Self { + Self { + permissions: value.permissions.into_iter().map(Into::into).collect(), + } + } +} + +#[wasm_bindgen(js_name = LinkedTable, getter_with_clone, inspectable)] +#[derive(Clone, Serialize, Deserialize)] +pub struct WasmLinkedTable { + pub id: String, + pub size: u64, + pub head: Option, + pub tail: Option, +} + +impl From> for WasmLinkedTable { + fn from(value: LinkedTable) -> Self { + Self { + id: value.id.to_string(), + size: value.size, + head: value.head, + tail: value.tail, + } + } +} + +#[wasm_bindgen(js_name = RoleAdminPermissions, getter_with_clone, inspectable)] +#[derive(Clone, Serialize, Deserialize)] +pub struct WasmRoleAdminPermissions { + pub add: WasmPermission, + pub delete: WasmPermission, + pub update: WasmPermission, +} + +impl From for WasmRoleAdminPermissions { + fn from(value: RoleAdminPermissions) -> Self { + Self { + add: value.add.into(), + delete: value.delete.into(), + update: value.update.into(), + } + } +} + +#[wasm_bindgen(js_name = CapabilityAdminPermissions, getter_with_clone, inspectable)] +#[derive(Clone, Serialize, Deserialize)] +pub struct WasmCapabilityAdminPermissions { + pub add: WasmPermission, + pub revoke: WasmPermission, +} + +impl From for WasmCapabilityAdminPermissions { + fn from(value: CapabilityAdminPermissions) -> Self { + Self { + add: value.add.into(), + revoke: value.revoke.into(), + } + } +} + +#[wasm_bindgen(js_name = RolePermissionsEntry, getter_with_clone, inspectable)] +#[derive(Clone)] +pub struct WasmRolePermissionsEntry { + pub name: String, + pub permissions: Vec, +} + +#[wasm_bindgen(js_name = RoleMap, getter_with_clone, inspectable)] +#[derive(Clone)] +pub struct WasmRoleMap { + #[wasm_bindgen(js_name = targetKey)] + pub target_key: String, + pub roles: Vec, + #[wasm_bindgen(js_name = initialAdminRoleName)] + pub initial_admin_role_name: String, + #[wasm_bindgen(js_name = issuedCapabilities)] + pub issued_capabilities: Vec, + #[wasm_bindgen(js_name = initialAdminCapIds)] + pub initial_admin_cap_ids: Vec, + #[wasm_bindgen(js_name = roleAdminPermissions)] + pub role_admin_permissions: WasmRoleAdminPermissions, + #[wasm_bindgen(js_name = capabilityAdminPermissions)] + pub capability_admin_permissions: WasmCapabilityAdminPermissions, +} + +impl From for WasmRoleMap { + fn from(value: RoleMap) -> Self { + Self { + target_key: value.target_key.to_string(), + roles: sorted_role_entries(value.roles), + initial_admin_role_name: value.initial_admin_role_name, + issued_capabilities: sorted_object_ids(value.issued_capabilities), + initial_admin_cap_ids: sorted_object_ids(value.initial_admin_cap_ids), + role_admin_permissions: value.role_admin_permissions.into(), + capability_admin_permissions: value.capability_admin_permissions.into(), + } + } +} + +#[wasm_bindgen(js_name = CapabilityIssueOptions, getter_with_clone, inspectable)] +#[derive(Clone, Default, Serialize, Deserialize)] +pub struct WasmCapabilityIssueOptions { + #[wasm_bindgen(js_name = issuedTo)] + pub issued_to: Option, + #[wasm_bindgen(js_name = validFromMs)] + pub valid_from_ms: Option, + #[wasm_bindgen(js_name = validUntilMs)] + pub valid_until_ms: Option, +} + +#[wasm_bindgen(js_class = CapabilityIssueOptions)] +impl WasmCapabilityIssueOptions { + #[wasm_bindgen(constructor)] + pub fn new(issued_to: Option, valid_from_ms: Option, valid_until_ms: Option) -> Self { + Self { + issued_to, + valid_from_ms, + valid_until_ms, + } + } +} + +impl From for WasmCapabilityIssueOptions { + fn from(value: CapabilityIssueOptions) -> Self { + Self { + issued_to: value.issued_to.map(|address| address.to_string()), + valid_from_ms: value.valid_from_ms, + valid_until_ms: value.valid_until_ms, + } + } +} + +impl From for CapabilityIssueOptions { + fn from(value: WasmCapabilityIssueOptions) -> Self { + Self { + issued_to: value.issued_to.and_then(|address| address.parse().ok()), + valid_from_ms: value.valid_from_ms, + valid_until_ms: value.valid_until_ms, + } + } +} + +#[wasm_bindgen(js_name = Capability, getter_with_clone, inspectable)] +#[derive(Clone)] +pub struct WasmCapability { + pub id: String, + #[wasm_bindgen(js_name = targetKey)] + pub target_key: String, + pub role: String, + #[wasm_bindgen(js_name = issuedTo)] + pub issued_to: Option, + #[wasm_bindgen(js_name = validFrom)] + pub valid_from: Option, + #[wasm_bindgen(js_name = validUntil)] + pub valid_until: Option, +} + +impl From for WasmCapability { + fn from(value: Capability) -> Self { + Self { + id: value.id.id.to_string(), + target_key: value.target_key.to_string(), + role: value.role, + issued_to: value.issued_to.map(|address| address.to_string()), + valid_from: value.valid_from, + valid_until: value.valid_until, + } + } +} + +#[wasm_bindgen(js_name = AuditTrailCreated, getter_with_clone, inspectable)] +#[derive(Clone)] +pub struct WasmAuditTrailCreated { + #[wasm_bindgen(js_name = trailId)] + pub trail_id: String, + pub creator: WasmIotaAddress, + pub timestamp: u64, +} + +impl From for WasmAuditTrailCreated { + fn from(value: AuditTrailCreated) -> Self { + Self { + trail_id: value.trail_id.to_string(), + creator: value.creator.to_string(), + timestamp: value.timestamp, + } + } +} + +#[wasm_bindgen(js_name = AuditTrailDeleted, getter_with_clone, inspectable)] +#[derive(Clone)] +pub struct WasmAuditTrailDeleted { + #[wasm_bindgen(js_name = trailId)] + pub trail_id: String, + pub timestamp: u64, +} + +impl From for WasmAuditTrailDeleted { + fn from(value: AuditTrailDeleted) -> Self { + Self { + trail_id: value.trail_id.to_string(), + timestamp: value.timestamp, + } + } +} + +#[wasm_bindgen(js_name = RecordAdded, getter_with_clone, inspectable)] +#[derive(Clone)] +pub struct WasmRecordAdded { + #[wasm_bindgen(js_name = trailId)] + pub trail_id: String, + #[wasm_bindgen(js_name = sequenceNumber)] + pub sequence_number: u64, + #[wasm_bindgen(js_name = addedBy)] + pub added_by: WasmIotaAddress, + pub timestamp: u64, +} + +impl From for WasmRecordAdded { + fn from(value: RecordAdded) -> Self { + Self { + trail_id: value.trail_id.to_string(), + sequence_number: value.sequence_number, + added_by: value.added_by.to_string(), + timestamp: value.timestamp, + } + } +} + +#[wasm_bindgen(js_name = RecordDeleted, getter_with_clone, inspectable)] +#[derive(Clone)] +pub struct WasmRecordDeleted { + #[wasm_bindgen(js_name = trailId)] + pub trail_id: String, + #[wasm_bindgen(js_name = sequenceNumber)] + pub sequence_number: u64, + #[wasm_bindgen(js_name = deletedBy)] + pub deleted_by: WasmIotaAddress, + pub timestamp: u64, +} + +impl From for WasmRecordDeleted { + fn from(value: RecordDeleted) -> Self { + Self { + trail_id: value.trail_id.to_string(), + sequence_number: value.sequence_number, + deleted_by: value.deleted_by.to_string(), + timestamp: value.timestamp, + } + } +} + +#[wasm_bindgen(js_name = CapabilityIssued, getter_with_clone, inspectable)] +#[derive(Clone)] +pub struct WasmCapabilityIssued { + #[wasm_bindgen(js_name = targetKey)] + pub target_key: String, + #[wasm_bindgen(js_name = capabilityId)] + pub capability_id: String, + pub role: String, + #[wasm_bindgen(js_name = issuedTo)] + pub issued_to: Option, + #[wasm_bindgen(js_name = validFrom)] + pub valid_from: Option, + #[wasm_bindgen(js_name = validUntil)] + pub valid_until: Option, +} + +impl From for WasmCapabilityIssued { + fn from(value: CapabilityIssued) -> Self { + Self { + target_key: value.target_key.to_string(), + capability_id: value.capability_id.to_string(), + role: value.role, + issued_to: value.issued_to.map(|address| address.to_string()), + valid_from: value.valid_from, + valid_until: value.valid_until, + } + } +} + +#[wasm_bindgen(js_name = CapabilityDestroyed, getter_with_clone, inspectable)] +#[derive(Clone)] +pub struct WasmCapabilityDestroyed { + #[wasm_bindgen(js_name = targetKey)] + pub target_key: String, + #[wasm_bindgen(js_name = capabilityId)] + pub capability_id: String, +} + +impl From for WasmCapabilityDestroyed { + fn from(value: CapabilityDestroyed) -> Self { + Self { + target_key: value.target_key.to_string(), + capability_id: value.capability_id.to_string(), + } + } +} + +#[wasm_bindgen(js_name = CapabilityRevoked, getter_with_clone, inspectable)] +#[derive(Clone)] +pub struct WasmCapabilityRevoked { + #[wasm_bindgen(js_name = targetKey)] + pub target_key: String, + #[wasm_bindgen(js_name = capabilityId)] + pub capability_id: String, +} + +impl From for WasmCapabilityRevoked { + fn from(value: CapabilityRevoked) -> Self { + Self { + target_key: value.target_key.to_string(), + capability_id: value.capability_id.to_string(), + } + } +} + +#[wasm_bindgen(js_name = RoleCreated, getter_with_clone, inspectable)] +#[derive(Clone)] +pub struct WasmRoleCreated { + #[wasm_bindgen(js_name = trailId)] + pub trail_id: String, + pub role: String, +} + +impl From for WasmRoleCreated { + fn from(value: RoleCreated) -> Self { + Self { + trail_id: value.trail_id.to_string(), + role: value.role, + } + } +} + +#[wasm_bindgen(js_name = RoleUpdated, getter_with_clone, inspectable)] +#[derive(Clone)] +pub struct WasmRoleUpdated { + #[wasm_bindgen(js_name = trailId)] + pub trail_id: String, + pub role: String, +} + +impl From for WasmRoleUpdated { + fn from(value: RoleUpdated) -> Self { + Self { + trail_id: value.trail_id.to_string(), + role: value.role, + } + } +} + +#[wasm_bindgen(js_name = RoleRemoved, getter_with_clone, inspectable)] +#[derive(Clone)] +pub struct WasmRoleRemoved { + #[wasm_bindgen(js_name = trailId)] + pub trail_id: String, + pub role: String, +} + +impl From for WasmRoleRemoved { + fn from(value: RoleRemoved) -> Self { + Self { + trail_id: value.trail_id.to_string(), + role: value.role, + } + } +} + #[wasm_bindgen(js_name = TimeLockType)] #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum WasmTimeLockType { From 2a7a755ee6711bed75758bfc2310abf6309a931c Mon Sep 17 00:00:00 2001 From: Yasir Date: Tue, 17 Mar 2026 22:02:18 +0300 Subject: [PATCH 077/189] chore: remove drift of the wasm data --- .../examples/src/03_add_and_list_records.ts | 5 +++-- .../examples/src/04_delete_records_batch.ts | 5 +++-- .../src/trail_handle/records.rs | 17 +++-------------- 3 files changed, 9 insertions(+), 18 deletions(-) diff --git a/bindings/wasm/audit_trails_wasm/examples/src/03_add_and_list_records.ts b/bindings/wasm/audit_trails_wasm/examples/src/03_add_and_list_records.ts index f122b845..3d7ce45b 100644 --- a/bindings/wasm/audit_trails_wasm/examples/src/03_add_and_list_records.ts +++ b/bindings/wasm/audit_trails_wasm/examples/src/03_add_and_list_records.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { strict as assert } from "assert"; +import { Data } from "../../web"; import { createTrailWithSeedRecord, getFundedClient, TEST_GAS_BUDGET } from "./util"; export async function addAndListRecords(): Promise { @@ -12,11 +13,11 @@ export async function addAndListRecords(): Promise { const records = client.trail(trail.id).records(); const addedString = await records - .addString("record 2", "second") + .add(Data.fromString("record 2"), "second") .withGasBudget(TEST_GAS_BUDGET) .buildAndExecute(client); const addedBytes = await records - .addBytes(Uint8Array.from([1, 2, 3, 4]), "third") + .add(Data.fromBytes(Uint8Array.from([1, 2, 3, 4])), "third") .withGasBudget(TEST_GAS_BUDGET) .buildAndExecute(client); diff --git a/bindings/wasm/audit_trails_wasm/examples/src/04_delete_records_batch.ts b/bindings/wasm/audit_trails_wasm/examples/src/04_delete_records_batch.ts index da1900d1..b89f050b 100644 --- a/bindings/wasm/audit_trails_wasm/examples/src/04_delete_records_batch.ts +++ b/bindings/wasm/audit_trails_wasm/examples/src/04_delete_records_batch.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { strict as assert } from "assert"; +import { Data } from "../../web"; import { createTrailWithSeedRecord, getFundedClient, TEST_GAS_BUDGET } from "./util"; export async function deleteRecordsBatch(): Promise { @@ -11,8 +12,8 @@ export async function deleteRecordsBatch(): Promise { const { output: trail } = await createTrailWithSeedRecord(client); const records = client.trail(trail.id).records(); - await records.addString("record 2", "second").withGasBudget(TEST_GAS_BUDGET).buildAndExecute(client); - await records.addString("record 3", "third").withGasBudget(TEST_GAS_BUDGET).buildAndExecute(client); + await records.add(Data.fromString("record 2"), "second").withGasBudget(TEST_GAS_BUDGET).buildAndExecute(client); + await records.add(Data.fromString("record 3"), "third").withGasBudget(TEST_GAS_BUDGET).buildAndExecute(client); const before = await records.recordCount(); const deleted = await records.deleteBatch(BigInt(2)).withGasBudget(TEST_GAS_BUDGET).buildAndExecute(client); diff --git a/bindings/wasm/audit_trails_wasm/src/trail_handle/records.rs b/bindings/wasm/audit_trails_wasm/src/trail_handle/records.rs index 72ba4dd2..26150c2b 100644 --- a/bindings/wasm/audit_trails_wasm/src/trail_handle/records.rs +++ b/bindings/wasm/audit_trails_wasm/src/trail_handle/records.rs @@ -109,24 +109,13 @@ impl WasmTrailRecords { Ok(WasmEmpty) } - #[wasm_bindgen(js_name = addString, unchecked_return_type = "TransactionBuilder")] - pub fn add_string(&self, data: String, metadata: Option) -> Result { + #[wasm_bindgen(unchecked_return_type = "TransactionBuilder")] + pub fn add(&self, data: WasmData, metadata: Option) -> Result { let tx = self .require_write()? .trail(self.trail_id) .records() - .add(AuditTrailData::text(data), metadata) - .into_inner(); - Ok(into_transaction_builder(WasmAddRecord(tx))) - } - - #[wasm_bindgen(js_name = addBytes, unchecked_return_type = "TransactionBuilder")] - pub fn add_bytes(&self, data: js_sys::Uint8Array, metadata: Option) -> Result { - let tx = self - .require_write()? - .trail(self.trail_id) - .records() - .add(AuditTrailData::bytes(data.to_vec()), metadata) + .add(AuditTrailData::from(data), metadata) .into_inner(); Ok(into_transaction_builder(WasmAddRecord(tx))) } From 8dbe1d49278f9c49a390b91b9de45148e5975e18 Mon Sep 17 00:00:00 2001 From: Yasir Date: Wed, 18 Mar 2026 11:21:36 +0300 Subject: [PATCH 078/189] fix record tag sdk wiring and clippy issues --- audit-trail-rs/src/client/full_client.rs | 5 +- audit-trail-rs/src/core/builder.rs | 2 +- audit-trail-rs/src/core/create/operations.rs | 38 ++++++++++----- .../src/core/create/transactions.rs | 17 ++++--- audit-trail-rs/src/core/operations.rs | 41 ---------------- audit-trail-rs/src/core/records/operations.rs | 4 +- audit-trail-rs/src/core/tags/mod.rs | 18 +------ audit-trail-rs/src/core/tags/operations.rs | 37 +-------------- audit-trail-rs/src/core/tags/transactions.rs | 47 ------------------- audit-trail-rs/src/core/types/permission.rs | 4 +- audit-trail-rs/src/core/types/record.rs | 4 +- audit-trail-rs/src/core/types/role_map.rs | 6 +-- audit-trail-rs/tests/e2e/client.rs | 3 +- audit-trail-rs/tests/e2e/main.rs | 2 +- audit-trail-rs/tests/e2e/trail.rs | 19 +------- examples/07_transfer_dynamic_notarization.rs | 2 +- examples/Cargo.toml | 1 + examples/utils/utils.rs | 2 +- 18 files changed, 54 insertions(+), 198 deletions(-) diff --git a/audit-trail-rs/src/client/full_client.rs b/audit-trail-rs/src/client/full_client.rs index 7d0fcf17..bd57f131 100644 --- a/audit-trail-rs/src/client/full_client.rs +++ b/audit-trail-rs/src/client/full_client.rs @@ -10,14 +10,13 @@ use std::ops::Deref; use async_trait::async_trait; #[cfg(not(target_arch = "wasm32"))] use iota_interaction::IotaClient; -use iota_interaction::types::base_types::ObjectID; +use iota_interaction::types::base_types::{IotaAddress, ObjectID}; +use iota_interaction::types::crypto::PublicKey; use iota_interaction::types::transaction::ProgrammableTransaction; use iota_interaction::{IotaKeySignature, OptionalSync}; use iota_interaction_rust::IotaClientAdapter; #[cfg(target_arch = "wasm32")] use iota_interaction_ts::bindings::WasmIotaClient as IotaClient; -use iota_sdk::types::base_types::IotaAddress; -use iota_sdk::types::crypto::PublicKey; use product_common::core_client::{CoreClient, CoreClientReadOnly}; use product_common::network_name::NetworkName; use secret_storage::Signer; diff --git a/audit-trail-rs/src/core/builder.rs b/audit-trail-rs/src/core/builder.rs index c256d219..51469a62 100644 --- a/audit-trail-rs/src/core/builder.rs +++ b/audit-trail-rs/src/core/builder.rs @@ -5,7 +5,7 @@ use std::collections::HashSet; -use iota_sdk::types::base_types::IotaAddress; +use iota_interaction::types::base_types::IotaAddress; use product_common::transaction::transaction_builder::TransactionBuilder; use super::types::{Data, ImmutableMetadata, LockingConfig}; diff --git a/audit-trail-rs/src/core/create/operations.rs b/audit-trail-rs/src/core/create/operations.rs index e35a9baa..3590b1b7 100644 --- a/audit-trail-rs/src/core/create/operations.rs +++ b/audit-trail-rs/src/core/create/operations.rs @@ -12,19 +12,32 @@ use crate::error::Error; pub(super) struct CreateOps; +pub(super) struct CreateTrailArgs { + pub audit_trail_package_id: ObjectID, + pub tf_components_package_id: ObjectID, + pub admin: IotaAddress, + pub initial_data: Option, + pub initial_record_metadata: Option, + pub locking_config: LockingConfig, + pub trail_metadata: Option, + pub updatable_metadata: Option, + pub available_record_tags: Vec, +} + impl CreateOps { - pub(super) fn create_trail( - audit_trail_package_id: ObjectID, - tf_components_package_id: ObjectID, - admin: IotaAddress, - initial_data: Option, - initial_record_metadata: Option, - locking_config: LockingConfig, - trail_metadata: Option, - updatable_metadata: Option, - available_record_tags: impl IntoIterator, - ) -> Result { + pub(super) fn create_trail(args: CreateTrailArgs) -> Result { let mut ptb = ProgrammableTransactionBuilder::new(); + let CreateTrailArgs { + audit_trail_package_id, + tf_components_package_id, + admin, + initial_data, + initial_record_metadata, + locking_config, + trail_metadata, + updatable_metadata, + mut available_record_tags, + } = args; let initial_data = initial_data.ok_or_else(|| { Error::InvalidArgument( @@ -32,7 +45,7 @@ impl CreateOps { ) })?; let data_tag = initial_data.tag(); - let initial_data_arg = initial_data.to_option_ptb(&mut ptb, "initial_data")?; + let initial_data_arg = initial_data.into_option_ptb(&mut ptb, "initial_data")?; let initial_record_metadata = utils::ptb_pure(&mut ptb, "initial_record_metadata", initial_record_metadata)?; let locking_config = locking_config.to_ptb(&mut ptb, audit_trail_package_id, tf_components_package_id)?; @@ -50,7 +63,6 @@ impl CreateOps { }; let updatable_metadata = utils::ptb_pure(&mut ptb, "updatable_metadata", updatable_metadata)?; - let mut available_record_tags = available_record_tags.into_iter().collect::>(); available_record_tags.sort(); let available_record_tags = utils::ptb_pure(&mut ptb, "available_record_tags", available_record_tags)?; let clock = utils::get_clock_ref(&mut ptb); diff --git a/audit-trail-rs/src/core/create/transactions.rs b/audit-trail-rs/src/core/create/transactions.rs index 1223f06f..676bbe01 100644 --- a/audit-trail-rs/src/core/create/transactions.rs +++ b/audit-trail-rs/src/core/create/transactions.rs @@ -4,14 +4,13 @@ use async_trait::async_trait; use iota_interaction::OptionalSync; use iota_interaction::rpc_types::{IotaTransactionBlockEffects, IotaTransactionBlockEvents}; -use iota_interaction::types::base_types::ObjectID; +use iota_interaction::types::base_types::{IotaAddress, ObjectID}; use iota_interaction::types::transaction::ProgrammableTransaction; -use iota_sdk::types::base_types::IotaAddress; use product_common::core_client::CoreClientReadOnly; use product_common::transaction::transaction_builder::Transaction; use tokio::sync::OnceCell; -use super::operations::CreateOps; +use super::operations::{CreateOps, CreateTrailArgs}; use crate::core::builder::AuditTrailBuilder; use crate::core::operations; use crate::core::types::{AuditTrailCreated, Event, OnChainAuditTrail}; @@ -73,17 +72,17 @@ impl CreateTrail { })?; let tf_components_package_id = package::tf_components_package_id(); - CreateOps::create_trail( - client.package_id(), + CreateOps::create_trail(CreateTrailArgs { + audit_trail_package_id: client.package_id(), tf_components_package_id, admin, - data, - record_metadata, + initial_data: data, + initial_record_metadata: record_metadata, locking_config, trail_metadata, updatable_metadata, - available_record_tags, - ) + available_record_tags: available_record_tags.into_iter().collect(), + }) } } diff --git a/audit-trail-rs/src/core/operations.rs b/audit-trail-rs/src/core/operations.rs index a5c53353..20fa30c0 100644 --- a/audit-trail-rs/src/core/operations.rs +++ b/audit-trail-rs/src/core/operations.rs @@ -94,47 +94,6 @@ where utils::get_object_ref_by_id(client, &object_id).await } -/// Finds a capability owned by `owner` whose role has all required permissions -/// according to the trail's RoleMap. -pub(crate) async fn find_capable_cap_with_permissions( - client: &C, - owner: IotaAddress, - trail_id: ObjectID, - trail: &OnChainAuditTrail, - required_permissions: &[Permission], -) -> Result -where - C: CoreClientReadOnly + OptionalSync, -{ - let valid_roles: HashSet<&String> = trail - .roles - .roles - .iter() - .filter(|(_, role)| { - required_permissions - .iter() - .all(|permission| role.permissions.contains(permission)) - }) - .map(|(name, _)| name) - .collect(); - - let cap: Capability = client - .find_object_for_address(owner, |cap: &Capability| { - cap.target_key == trail_id && valid_roles.contains(&cap.role) - }) - .await - .map_err(|e| Error::RpcError(e.to_string()))? - .ok_or_else(|| { - Error::InvalidArgument(format!( - "no capability with {:?} permissions found for owner {owner} and trail {trail_id}", - required_permissions - )) - })?; - - let object_id = *cap.id.object_id(); - utils::get_object_ref_by_id(client, &object_id).await -} - pub(crate) async fn build_trail_transaction_with_cap_ref( client: &C, trail_id: ObjectID, diff --git a/audit-trail-rs/src/core/records/operations.rs b/audit-trail-rs/src/core/records/operations.rs index a7c864e0..6a28d650 100644 --- a/audit-trail-rs/src/core/records/operations.rs +++ b/audit-trail-rs/src/core/records/operations.rs @@ -41,7 +41,7 @@ impl RecordsOps { |ptb, trail_tag| { data.ensure_matches_tag(trail_tag)?; - let data_arg = data.to_ptb(ptb, "stored_data")?; + let data_arg = data.into_ptb(ptb, "stored_data")?; let metadata = utils::ptb_pure(ptb, "record_metadata", record_metadata)?; let tag_arg = utils::ptb_pure(ptb, "record_tag", Some(tag))?; let clock = utils::get_clock_ref(ptb); @@ -59,7 +59,7 @@ impl RecordsOps { |ptb, trail_tag| { data.ensure_matches_tag(trail_tag)?; - let data_arg = data.to_ptb(ptb, "stored_data")?; + let data_arg = data.into_ptb(ptb, "stored_data")?; let metadata = utils::ptb_pure(ptb, "record_metadata", record_metadata)?; let tag = utils::ptb_pure(ptb, "record_tag", Option::::None)?; let clock = utils::get_clock_ref(ptb); diff --git a/audit-trail-rs/src/core/tags/mod.rs b/audit-trail-rs/src/core/tags/mod.rs index 1a06cfd6..3049b2f5 100644 --- a/audit-trail-rs/src/core/tags/mod.rs +++ b/audit-trail-rs/src/core/tags/mod.rs @@ -12,7 +12,7 @@ use crate::core::trail::AuditTrailFull; mod operations; mod transactions; -pub use transactions::{AddRecordTag, RemoveRecordTag, SetRecordTags}; +pub use transactions::{AddRecordTag, RemoveRecordTag}; #[derive(Debug, Clone)] pub struct TrailTags<'a, C> { @@ -44,20 +44,4 @@ impl<'a, C> TrailTags<'a, C> { let owner = self.client.sender_address(); TransactionBuilder::new(RemoveRecordTag::new(self.trail_id, owner, tag.into())) } - - /// Replaces the entire trail-owned record-tag registry. - pub fn set(&self, tags: I) -> TransactionBuilder - where - C: AuditTrailFull + CoreClient, - S: Signer + OptionalSync, - I: IntoIterator, - T: Into, - { - let owner = self.client.sender_address(); - TransactionBuilder::new(SetRecordTags::new( - self.trail_id, - owner, - tags.into_iter().map(Into::into).collect(), - )) - } } diff --git a/audit-trail-rs/src/core/tags/operations.rs b/audit-trail-rs/src/core/tags/operations.rs index c7b4782c..5d63c8f3 100644 --- a/audit-trail-rs/src/core/tags/operations.rs +++ b/audit-trail-rs/src/core/tags/operations.rs @@ -27,7 +27,7 @@ impl TagsOps { trail_id, owner, Permission::AddRecordTags, - "add_available_record_tag", + "add_record_tag", |ptb, _| { let tag_arg = utils::ptb_pure(ptb, "tag", tag)?; let clock = utils::get_clock_ref(ptb); @@ -51,7 +51,7 @@ impl TagsOps { trail_id, owner, Permission::DeleteRecordTags, - "remove_available_record_tag", + "remove_record_tag", |ptb, _| { let tag_arg = utils::ptb_pure(ptb, "tag", tag)?; let clock = utils::get_clock_ref(ptb); @@ -60,37 +60,4 @@ impl TagsOps { ) .await } - - pub(super) async fn set_record_tags( - client: &C, - trail_id: ObjectID, - owner: IotaAddress, - tags: Vec, - ) -> Result - where - C: CoreClientReadOnly + OptionalSync, - { - let trail = operations::get_audit_trail(trail_id, client).await?; - let cap_ref = operations::find_capable_cap_with_permissions( - client, - owner, - trail_id, - &trail, - &[Permission::AddRecordTags, Permission::DeleteRecordTags], - ) - .await?; - - operations::build_trail_transaction_with_cap_ref( - client, - trail_id, - cap_ref, - "set_available_record_tags", - |ptb, _| { - let tags_arg = utils::ptb_pure(ptb, "tags", tags)?; - let clock = utils::get_clock_ref(ptb); - Ok(vec![tags_arg, clock]) - }, - ) - .await - } } diff --git a/audit-trail-rs/src/core/tags/transactions.rs b/audit-trail-rs/src/core/tags/transactions.rs index af4c5aa2..7f310926 100644 --- a/audit-trail-rs/src/core/tags/transactions.rs +++ b/audit-trail-rs/src/core/tags/transactions.rs @@ -106,50 +106,3 @@ impl Transaction for RemoveRecordTag { Ok(()) } } - -#[derive(Debug, Clone)] -pub struct SetRecordTags { - trail_id: ObjectID, - owner: IotaAddress, - tags: Vec, - cached_ptb: OnceCell, -} - -impl SetRecordTags { - pub fn new(trail_id: ObjectID, owner: IotaAddress, tags: Vec) -> Self { - Self { - trail_id, - owner, - tags, - cached_ptb: OnceCell::new(), - } - } - - async fn make_ptb(&self, client: &C) -> Result - where - C: CoreClientReadOnly + OptionalSync, - { - TagsOps::set_record_tags(client, self.trail_id, self.owner, self.tags.clone()).await - } -} - -#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] -#[cfg_attr(feature = "send-sync", async_trait)] -impl Transaction for SetRecordTags { - type Error = Error; - type Output = (); - - async fn build_programmable_transaction(&self, client: &C) -> Result - where - C: CoreClientReadOnly + OptionalSync, - { - self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() - } - - async fn apply(self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result - where - C: CoreClientReadOnly + OptionalSync, - { - Ok(()) - } -} diff --git a/audit-trail-rs/src/core/types/permission.rs b/audit-trail-rs/src/core/types/permission.rs index 55b046cd..a7b49ac7 100644 --- a/audit-trail-rs/src/core/types/permission.rs +++ b/audit-trail-rs/src/core/types/permission.rs @@ -67,7 +67,7 @@ impl Permission { TypeTag::from_str(&format!("{package_id}::permission::Permission")).expect("invalid TypeTag for Permission") } - pub(in crate::core) fn to_ptb(&self, ptb: &mut Ptb, package_id: ObjectID) -> Result { + pub(in crate::core) fn to_ptb(self, ptb: &mut Ptb, package_id: ObjectID) -> Result { let function = Identifier::from_str(self.function_name()) .map_err(|e| Error::InvalidArgument(format!("Failed to create identifier for function: {e}")))?; @@ -87,7 +87,7 @@ impl PermissionSet { let permission_args: Vec<_> = self .permissions .iter() - .map(|permission| permission.to_ptb(ptb, package_id)) + .map(|permission| (*permission).to_ptb(ptb, package_id)) .collect::, _>>()?; Ok(ptb.command(Command::MakeMoveVec(Some(permission_type.into()), permission_args))) diff --git a/audit-trail-rs/src/core/types/record.rs b/audit-trail-rs/src/core/types/record.rs index db472c34..ffefe2a9 100644 --- a/audit-trail-rs/src/core/types/record.rs +++ b/audit-trail-rs/src/core/types/record.rs @@ -96,7 +96,7 @@ impl Data { } /// Creates a PTB argument for `D` where `D` is the concrete Move data type. - pub(in crate::core) fn to_ptb(self, ptb: &mut Ptb, name: &str) -> Result { + pub(in crate::core) fn into_ptb(self, ptb: &mut Ptb, name: &str) -> Result { match self { Data::Bytes(bytes) => utils::ptb_pure(ptb, name, bytes), Data::Text(text) => utils::ptb_pure(ptb, name, text), @@ -104,7 +104,7 @@ impl Data { } /// Creates a PTB argument for `Option` where `D` is the concrete Move data type. - pub(in crate::core) fn to_option_ptb(self, ptb: &mut Ptb, name: &str) -> Result { + pub(in crate::core) fn into_option_ptb(self, ptb: &mut Ptb, name: &str) -> Result { match self { Data::Bytes(bytes) => utils::ptb_pure(ptb, name, Some(bytes)), Data::Text(text) => utils::ptb_pure(ptb, name, Some(text)), diff --git a/audit-trail-rs/src/core/types/role_map.rs b/audit-trail-rs/src/core/types/role_map.rs index de45d150..c0943cb7 100644 --- a/audit-trail-rs/src/core/types/role_map.rs +++ b/audit-trail-rs/src/core/types/role_map.rs @@ -4,13 +4,12 @@ use std::collections::{HashMap, HashSet}; use std::str::FromStr; -use iota_interaction::MoveType; -use iota_interaction::ident_str; use iota_interaction::types::TypeTag; use iota_interaction::types::base_types::{IotaAddress, ObjectID}; use iota_interaction::types::id::UID; use iota_interaction::types::programmable_transaction_builder::ProgrammableTransactionBuilder as Ptb; use iota_interaction::types::transaction::Argument; +use iota_interaction::{MoveType, ident_str}; use serde::{Deserialize, Serialize}; use super::permission::Permission; @@ -85,8 +84,7 @@ impl RecordTags { } pub(crate) fn tag(package_id: ObjectID) -> TypeTag { - TypeTag::from_str(&format!("{package_id}::record_tags::RecordTags")) - .expect("invalid TypeTag for RecordTags") + TypeTag::from_str(&format!("{package_id}::record_tags::RecordTags")).expect("invalid TypeTag for RecordTags") } pub(in crate::core) fn to_ptb(&self, ptb: &mut Ptb, package_id: ObjectID) -> Result { diff --git a/audit-trail-rs/tests/e2e/client.rs b/audit-trail-rs/tests/e2e/client.rs index 3b21287e..ab16622c 100644 --- a/audit-trail-rs/tests/e2e/client.rs +++ b/audit-trail-rs/tests/e2e/client.rs @@ -9,11 +9,10 @@ use audit_trails::AuditTrailClient; use audit_trails::core::types::{ Capability, CapabilityIssueOptions, CapabilityIssued, Data, Permission, PermissionSet, RecordTags, RoleCreated, }; -use iota_interaction::types::base_types::{IotaAddress, ObjectID}; +use iota_interaction::types::base_types::{IotaAddress, ObjectID, ObjectRef}; use iota_interaction::types::crypto::PublicKey; use iota_interaction::{IOTA_LOCAL_NETWORK_URL, IotaClientBuilder}; use iota_interaction_rust::IotaClientAdapter; -use iota_sdk::types::base_types::ObjectRef; use product_common::core_client::{CoreClient, CoreClientReadOnly}; use product_common::network_name::NetworkName; use product_common::test_utils::{InMemSigner, init_product_package, request_funds}; diff --git a/audit-trail-rs/tests/e2e/main.rs b/audit-trail-rs/tests/e2e/main.rs index 2cfc33c1..f33ba495 100644 --- a/audit-trail-rs/tests/e2e/main.rs +++ b/audit-trail-rs/tests/e2e/main.rs @@ -3,8 +3,8 @@ // Rust tests for Audit Trails have been temporarily deactivated during development. // Uncomment the following modules to re-enable them. +mod access; mod client; mod locking; mod records; -mod access; mod trail; diff --git a/audit-trail-rs/tests/e2e/trail.rs b/audit-trail-rs/tests/e2e/trail.rs index b83fe6f0..d93a4a44 100644 --- a/audit-trail-rs/tests/e2e/trail.rs +++ b/audit-trail-rs/tests/e2e/trail.rs @@ -417,25 +417,10 @@ async fn manage_record_tag_registry_roundtrip() -> anyhow::Result<()> { trail.tags().add("legal").build_and_execute(&client).await?; let after_add = trail.get().await?; - assert!( - after_add - .tags - .contents - .contains(&"finance".to_string()) - ); + assert!(after_add.tags.contents.contains(&"finance".to_string())); assert!(after_add.tags.contents.contains(&"legal".to_string())); - let after_set = trail.get().await?; - assert!( - after_set - .tags - .contents - .contains(&"finance".to_string()) - ); - assert!(after_set.tags.contents.contains(&"hr".to_string())); - assert!(!after_set.tags.contents.contains(&"legal".to_string())); - - trail.tags().remove("hr").build_and_execute(&client).await?; + trail.tags().remove("legal").build_and_execute(&client).await?; let after_remove = trail.get().await?; assert_eq!(after_remove.tags.contents, vec!["finance".to_string()]); diff --git a/examples/07_transfer_dynamic_notarization.rs b/examples/07_transfer_dynamic_notarization.rs index b0473665..b5bbffd6 100644 --- a/examples/07_transfer_dynamic_notarization.rs +++ b/examples/07_transfer_dynamic_notarization.rs @@ -5,7 +5,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::Result; use examples::get_funded_client; -use iota_sdk::types::base_types::IotaAddress; +use iota_interaction::types::base_types::IotaAddress; use notarization::core::types::{State, TimeLock}; #[tokio::main] diff --git a/examples/Cargo.toml b/examples/Cargo.toml index ed25793b..4a3d4447 100644 --- a/examples/Cargo.toml +++ b/examples/Cargo.toml @@ -51,6 +51,7 @@ path = "real-world/02_legal_contract.rs" [dependencies] anyhow.workspace = true chrono = { workspace = true } +iota_interaction = { workspace = true } iota-sdk = { workspace = true } notarization = { path = "../notarization-rs" } product_common = { workspace = true, features = ["core-client", "test-utils", "transaction"] } diff --git a/examples/utils/utils.rs b/examples/utils/utils.rs index 5f918f9c..388dfb1f 100644 --- a/examples/utils/utils.rs +++ b/examples/utils/utils.rs @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 use anyhow::Context; -use iota_sdk::{IOTA_LOCAL_NETWORK_URL, IotaClientBuilder}; +use iota_interaction::{IOTA_LOCAL_NETWORK_URL, IotaClientBuilder}; use notarization::client::{NotarizationClient, NotarizationClientReadOnly}; use product_common::test_utils::{InMemSigner, request_funds}; From 186e1c360b7f78a093c00003ab414c3c06cfb4c5 Mon Sep 17 00:00:00 2001 From: Yasir Date: Wed, 18 Mar 2026 11:24:19 +0300 Subject: [PATCH 079/189] chore: fix fmt issues --- audit-trail-move/Move.toml | 3 ++- examples/Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/audit-trail-move/Move.toml b/audit-trail-move/Move.toml index 9914bbea..cef7749d 100644 --- a/audit-trail-move/Move.toml +++ b/audit-trail-move/Move.toml @@ -3,7 +3,8 @@ name = "audit_trail" edition = "2024.beta" [dependencies] -TfComponents = { local = "../../product-core/components_move"} +# TODO: Use git tag +TfComponents = { local = "../../product-core/components_move" } [addresses] audit_trail = "0x0" diff --git a/examples/Cargo.toml b/examples/Cargo.toml index 4a3d4447..7439ac65 100644 --- a/examples/Cargo.toml +++ b/examples/Cargo.toml @@ -51,8 +51,8 @@ path = "real-world/02_legal_contract.rs" [dependencies] anyhow.workspace = true chrono = { workspace = true } -iota_interaction = { workspace = true } iota-sdk = { workspace = true } +iota_interaction = { workspace = true } notarization = { path = "../notarization-rs" } product_common = { workspace = true, features = ["core-client", "test-utils", "transaction"] } serde_json = { workspace = true } From d083df8ea402692d3a8c163edf2408a7c0d9fc22 Mon Sep 17 00:00:00 2001 From: Yasir Date: Wed, 18 Mar 2026 15:58:43 +0300 Subject: [PATCH 080/189] chore: allow unused tag deletion --- audit-trail-move/Move.lock | 4 +- audit-trail-move/sources/audit_trail.move | 112 +++++++++++++++--- audit-trail-move/sources/record_tags.move | 58 +++++---- .../tests/create_audit_trail_tests.move | 8 +- audit-trail-rs/src/core/access/operations.rs | 2 +- audit-trail-rs/src/core/records/operations.rs | 2 +- audit-trail-rs/src/core/types/audit_trail.rs | 8 +- audit-trail-rs/tests/e2e/trail.rs | 10 +- 8 files changed, 147 insertions(+), 57 deletions(-) diff --git a/audit-trail-move/Move.lock b/audit-trail-move/Move.lock index f48fc604..2a620883 100644 --- a/audit-trail-move/Move.lock +++ b/audit-trail-move/Move.lock @@ -2,7 +2,7 @@ [move] version = 3 -manifest_digest = "E2DCED5F45474DE4CA933A99DEAB29171001E7D699076C9B2528FA3A74FC2FCE" +manifest_digest = "E922E01581B08538BED02DDE9B7C6990033C8BA3626329255269DCBDFD34AD21" deps_digest = "397E6A9F7A624706DBDFEE056CE88391A15876868FD18A88504DA74EB458D697" dependencies = [ { id = "Iota", name = "Iota" }, @@ -44,7 +44,7 @@ dependencies = [ [[move.package]] id = "TfComponents" -source = { git = "https://github.com/iotaledger/product-core.git", rev = "feat/tf-compoenents-dev", subdir = "components_move" } +source = { local = "../../product-core/components_move" } dependencies = [ { id = "Iota", name = "Iota" }, diff --git a/audit-trail-move/sources/audit_trail.move b/audit-trail-move/sources/audit_trail.move index ce3c3c90..eca31390 100644 --- a/audit-trail-move/sources/audit_trail.move +++ b/audit-trail-move/sources/audit_trail.move @@ -21,7 +21,7 @@ use audit_trail::{ record::{Self, Record}, record_tags::{Self, RecordTags} }; -use iota::{clock::{Self, Clock}, vec_set, event, linked_table::{Self, LinkedTable}, vec_set::VecSet}; +use iota::{clock::{Self, Clock}, event, linked_table::{Self, LinkedTable}, vec_map::{Self, VecMap}, vec_set::VecSet}; use std::string::String; use tf_components::{capability::Capability, role_map::{Self, RoleMap}, timelock::TimeLock}; @@ -80,8 +80,8 @@ public struct AuditTrail has key, store { sequence_number: u64, /// LinkedTable mapping sequence numbers to records records: LinkedTable>, - /// Canonical list of tags that may be attached to records in this trail - tags: VecSet, + /// Canonical list of tags that may be attached to records in this trail with their combined usage counts + tags: VecMap, /// Deletion locking rules locking_config: LockingConfig, /// A list of role definitions consisting of a unique role specifier and a list of associated permissions @@ -214,13 +214,15 @@ public fun create( ctx, ); + let tags = record_tags::new_usage(tags); + let trail = AuditTrail { id: trail_uid, creator, created_at: timestamp, sequence_number, records, - tags: vec_set::from_keys(tags), + tags, locking_config, roles, immutable_metadata: trail_metadata, @@ -307,6 +309,10 @@ public fun add_record( let trail_id = self.id(); let seq = self.sequence_number; + if (record_tag.is_some()) { + record_tags::increment_tag_usage(&mut self.tags, option::borrow(&record_tag)); + }; + let record = record::new( stored_data, record_metadata, @@ -356,6 +362,9 @@ public fun delete_record( let trail_id = self.id(); let record = linked_table::remove(&mut self.records, sequence_number); + if (record::tag(&record).is_some()) { + record_tags::decrement_tag_usage(&mut self.tags, option::borrow(record::tag(&record))); + }; record::destroy(record); event::emit(RecordDeleted { @@ -395,6 +404,10 @@ public fun delete_records_batch( while (deleted < limit && !self.records.is_empty()) { let (sequence_number, record) = self.records.pop_front(); + if (record::tag(&record).is_some()) { + record_tags::decrement_tag_usage(&mut self.tags, option::borrow(record::tag(&record))); + }; + record.destroy(); event::emit(RecordDeleted { @@ -440,15 +453,20 @@ public fun delete_audit_trail( created_at: _, sequence_number: _, records, - tags: _, + mut tags, locking_config: _, - roles: _, + roles, immutable_metadata: _, updatable_metadata: _, version: _, } = self; linked_table::destroy_empty(records); + while (!vec_map::is_empty(&tags)) { + let (_, _) = vec_map::pop(&mut tags); + }; + vec_map::destroy_empty(tags); + role_map::destroy(roles); object::delete(id); event::emit(AuditTrailDeleted { trail_id, timestamp }); @@ -589,8 +607,8 @@ public fun add_record_tag( self.roles.assert_capability_valid(cap, &permission::add_record_tags(), clock, ctx); - assert!(!iota::vec_set::contains(&self.tags, &tag), ERecordTagAlreadyDefined); - self.tags.insert(tag); + assert!(!iota::vec_map::contains(&self.tags, &tag), ERecordTagAlreadyDefined); + vec_map::insert(&mut self.tags, tag, 0); } /// Removes a record tag from the trail registry if it is not used by any record. @@ -605,9 +623,9 @@ public fun remove_record_tag( self.roles.assert_capability_valid(cap, &permission::delete_record_tags(), clock, ctx); - assert!(iota::vec_set::contains(&self.tags, &tag), ERecordTagNotDefined); - assert!(!record_tags::is_in_use(&self.records, self.sequence_number, &tag), ERecordTagInUse); - self.tags.remove(&tag); + assert!(iota::vec_map::contains(&self.tags, &tag), ERecordTagNotDefined); + assert!(record_tags::usage_count(&self.tags, &tag) == 0, ERecordTagInUse); + vec_map::remove(&mut self.tags, &tag); } // ===== Role and Capability Administration ===== @@ -631,10 +649,21 @@ public fun create_role( cap, role, permissions, - record_tags, + copy record_tags, clock, ctx, ); + + if (record_tags.is_some()) { + let tags = iota::vec_set::keys(record_tags::allowed_record_tags(option::borrow(&record_tags))); + let mut i = 0; + let tag_count = tags.length(); + + while (i < tag_count) { + record_tags::increment_tag_usage(&mut self.tags, &tags[i]); + i = i + 1; + }; + }; } /// Updates permissions for an existing role. @@ -650,15 +679,38 @@ public fun update_role_permissions( assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); assert!(record_tags::defined_for_trail(&self.tags, &record_tags), ERecordTagNotDefined); + let old_record_tags = *role_map::get_role_data(self.access(), &role); role_map::update_role( self.access_mut(), cap, &role, new_permissions, - record_tags, + copy record_tags, clock, ctx, ); + + if (old_record_tags.is_some()) { + let tags = iota::vec_set::keys(record_tags::allowed_record_tags(option::borrow(&old_record_tags))); + let mut i = 0; + let tag_count = tags.length(); + + while (i < tag_count) { + record_tags::decrement_tag_usage(&mut self.tags, &tags[i]); + i = i + 1; + }; + }; + + if (record_tags.is_some()) { + let tags = iota::vec_set::keys(record_tags::allowed_record_tags(option::borrow(&record_tags))); + let mut i = 0; + let tag_count = tags.length(); + + while (i < tag_count) { + record_tags::increment_tag_usage(&mut self.tags, &tags[i]); + i = i + 1; + }; + }; } /// Deletes an existing role. @@ -670,7 +722,19 @@ public fun delete_role( ctx: &mut TxContext, ) { assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + let old_record_tags = *role_map::get_role_data(self.access(), &role); role_map::delete_role(self.access_mut(), cap, &role, clock, ctx); + + if (old_record_tags.is_some()) { + let tags = iota::vec_set::keys(record_tags::allowed_record_tags(option::borrow(&old_record_tags))); + let mut i = 0; + let tag_count = tags.length(); + + while (i < tag_count) { + record_tags::decrement_tag_usage(&mut self.tags, &tags[i]); + i = i + 1; + }; + }; } /// Issues a new capability for an existing role. @@ -717,7 +781,14 @@ public fun revoke_capability( ctx: &mut TxContext, ) { assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); - role_map::revoke_capability(self.access_mut(), cap, capability_id, clock, ctx); + role_map::revoke_capability( + self.access_mut(), + cap, + capability_id, + option::none(), + clock, + ctx, + ); } /// Destroys a capability object. @@ -771,7 +842,14 @@ public fun revoke_initial_admin_capability( ctx: &mut TxContext, ) { assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); - role_map::revoke_initial_admin_capability(self.access_mut(), cap, capability_id, clock, ctx); + role_map::revoke_initial_admin_capability( + self.access_mut(), + cap, + capability_id, + option::none(), + clock, + ctx, + ); } // ===== Trail Query Functions ===== @@ -825,8 +903,8 @@ public fun locking_config(self: &AuditTrail): &LockingConfig &self.locking_config } -/// Get the trail-defined record tags. -public fun tags(self: &AuditTrail): &VecSet { +/// Get the trail-defined record tags and their combined usage counts. +public fun tags(self: &AuditTrail): &VecMap { &self.tags } diff --git a/audit-trail-move/sources/record_tags.move b/audit-trail-move/sources/record_tags.move index 210bfb89..17f7f907 100644 --- a/audit-trail-move/sources/record_tags.move +++ b/audit-trail-move/sources/record_tags.move @@ -4,8 +4,8 @@ /// Record tag types and helper predicates for audit trails. module audit_trail::record_tags; -use audit_trail::{permission::Permission, record::{Self, Record}}; -use iota::{linked_table::{Self, LinkedTable}, vec_set::{Self, VecSet}}; +use audit_trail::permission::Permission; +use iota::{vec_map::{Self, VecMap}, vec_set::{Self, VecSet}}; use std::string::String; use tf_components::{capability::Capability, role_map::{Self, RoleMap}}; @@ -26,9 +26,21 @@ public fun allowed_record_tags(record_tags: &RecordTags): &VecSet { &record_tags.tags } +/// Create a zeroed usage counter for all tags in the trail list +public(package) fun new_usage(mut tags: vector): VecMap { + let mut usage = vec_map::empty(); + tags.reverse(); + + while (tags.length() != 0) { + vec_map::insert(&mut usage, tags.pop_back(), 0); + }; + + usage +} + /// Returns true when all provided role tags are defined on the trail. public(package) fun defined_for_trail( - available_tags: &VecSet, + available_tags: &VecMap, record_tags: &Option, ): bool { if (!record_tags.is_some()) { @@ -41,7 +53,7 @@ public(package) fun defined_for_trail( let tag_count = allowed_tag_keys.length(); while (i < tag_count) { - if (!iota::vec_set::contains(available_tags, &allowed_tag_keys[i])) { + if (!iota::vec_map::contains(available_tags, &allowed_tag_keys[i])) { return false }; i = i + 1; @@ -51,8 +63,8 @@ public(package) fun defined_for_trail( } /// Returns true when the requested tag exists in the trail registry. -public(package) fun is_defined(available_tags: &VecSet, tag: &String): bool { - iota::vec_set::contains(available_tags, tag) +public(package) fun is_defined(available_tags: &VecMap, tag: &String): bool { + iota::vec_map::contains(available_tags, tag) } /// Returns true when the capability's role data allows the requested tag. @@ -70,25 +82,21 @@ public(package) fun role_allows( iota::vec_set::contains(tags, tag) } -/// Returns true when any live record currently uses the provided tag. -public(package) fun is_in_use( - records: &LinkedTable>, - sequence_number: u64, - tag: &String, -): bool { - let mut current_sequence = 0; - while (current_sequence < sequence_number) { - if (linked_table::contains(records, current_sequence)) { - let stored_record = linked_table::borrow(records, current_sequence); - let record_tag = record::tag(stored_record); - - if (record_tag.is_some() && option::borrow(record_tag) == tag) { - return true - }; - }; +/// Returns the current combined usage count for a tag across records and roles. +public(package) fun usage_count(usage: &VecMap, tag: &String): u64 { + if (vec_map::contains(usage, tag)) { + *vec_map::get(usage, tag) + } else { + 0 + } +} - current_sequence = current_sequence + 1; - }; +public(package) fun increment_tag_usage(usage: &mut VecMap, tag: &String) { + let count = vec_map::get_mut(usage, tag); + *count = *count + 1; +} - false +public(package) fun decrement_tag_usage(usage: &mut VecMap, tag: &String) { + let count = vec_map::get_mut(usage, tag); + *count = *count - 1; } diff --git a/audit-trail-move/tests/create_audit_trail_tests.move b/audit-trail-move/tests/create_audit_trail_tests.move index aa39d22c..4778a105 100644 --- a/audit-trail-move/tests/create_audit_trail_tests.move +++ b/audit-trail-move/tests/create_audit_trail_tests.move @@ -16,7 +16,7 @@ use audit_trail::{ new_capability_for_address } }; -use iota::{clock, test_scenario as ts, vec_set}; +use iota::{clock, test_scenario as ts, vec_map, vec_set}; use std::string; use tf_components::timelock; @@ -122,8 +122,8 @@ fun test_tag_admin_role_can_manage_available_record_tags() { ); let available_tags = trail.tags(); - assert!(vec_set::size(available_tags) == 1, 0); - assert!(vec_set::contains(available_tags, &string::utf8(b"finance")), 1); + assert!(vec_map::size(available_tags) == 1, 0); + assert!(vec_map::contains(available_tags, &string::utf8(b"finance")), 1); trail.remove_record_tag( &tag_admin_cap, @@ -133,7 +133,7 @@ fun test_tag_admin_role_can_manage_available_record_tags() { ); let available_tags = trail.tags(); - assert!(vec_set::size(available_tags) == 0, 0); + assert!(vec_map::size(available_tags) == 0, 0); cleanup_capability_trail_and_clock(&scenario, tag_admin_cap, trail, clock); }; diff --git a/audit-trail-rs/src/core/access/operations.rs b/audit-trail-rs/src/core/access/operations.rs index cd81fe6c..08b7a4d6 100644 --- a/audit-trail-rs/src/core/access/operations.rs +++ b/audit-trail-rs/src/core/access/operations.rs @@ -277,7 +277,7 @@ where let undefined_tags = record_tags .allowed_tags .iter() - .filter(|tag| !trail.tags.contents.iter().any(|available_tag| available_tag == *tag)) + .filter(|tag| !trail.tags.contains_key(*tag)) .cloned() .collect::>(); diff --git a/audit-trail-rs/src/core/records/operations.rs b/audit-trail-rs/src/core/records/operations.rs index 6a28d650..6e203fc3 100644 --- a/audit-trail-rs/src/core/records/operations.rs +++ b/audit-trail-rs/src/core/records/operations.rs @@ -26,7 +26,7 @@ impl RecordsOps { { if let Some(tag) = record_tag.clone() { let trail = operations::get_audit_trail(trail_id, client).await?; - if !trail.tags.contents.iter().any(|allowed_tag| allowed_tag == &tag) { + if !trail.tags.contains_key(&tag) { return Err(Error::InvalidArgument(format!( "record tag '{tag}' is not defined for trail {trail_id}" ))); diff --git a/audit-trail-rs/src/core/types/audit_trail.rs b/audit-trail-rs/src/core/types/audit_trail.rs index 5882fd31..4d55a6db 100644 --- a/audit-trail-rs/src/core/types/audit_trail.rs +++ b/audit-trail-rs/src/core/types/audit_trail.rs @@ -1,12 +1,13 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +use std::collections::HashMap; use std::str::FromStr; use iota_interaction::ident_str; use iota_interaction::types::TypeTag; use iota_interaction::types::base_types::{IotaAddress, ObjectID}; -use iota_interaction::types::collection_types::{LinkedTable, VecSet}; +use iota_interaction::types::collection_types::LinkedTable; use iota_interaction::types::id::UID; use iota_interaction::types::programmable_transaction_builder::ProgrammableTransactionBuilder as Ptb; use iota_interaction::types::transaction::Argument; @@ -14,7 +15,7 @@ use serde::{Deserialize, Serialize}; use super::locking::LockingConfig; use super::role_map::RoleMap; -use crate::core::utils; +use crate::core::utils::{self, deserialize_vec_map}; use crate::error::Error; /// An audit trail stored on-chain. @@ -25,7 +26,8 @@ pub struct OnChainAuditTrail { pub created_at: u64, pub sequence_number: u64, pub records: LinkedTable, - pub tags: VecSet, + #[serde(deserialize_with = "deserialize_vec_map")] + pub tags: HashMap, pub locking_config: LockingConfig, pub roles: RoleMap, pub immutable_metadata: Option, diff --git a/audit-trail-rs/tests/e2e/trail.rs b/audit-trail-rs/tests/e2e/trail.rs index d93a4a44..da7bffd4 100644 --- a/audit-trail-rs/tests/e2e/trail.rs +++ b/audit-trail-rs/tests/e2e/trail.rs @@ -413,17 +413,19 @@ async fn manage_record_tag_registry_roundtrip() -> anyhow::Result<()> { let trail = client.trail(created.trail_id); let initial = trail.get().await?; - assert_eq!(initial.tags.contents, vec!["finance".to_string()]); + assert_eq!(initial.tags.len(), 1); + assert!(initial.tags.contains_key("finance")); trail.tags().add("legal").build_and_execute(&client).await?; let after_add = trail.get().await?; - assert!(after_add.tags.contents.contains(&"finance".to_string())); - assert!(after_add.tags.contents.contains(&"legal".to_string())); + assert!(after_add.tags.contains_key("finance")); + assert!(after_add.tags.contains_key("legal")); trail.tags().remove("legal").build_and_execute(&client).await?; let after_remove = trail.get().await?; - assert_eq!(after_remove.tags.contents, vec!["finance".to_string()]); + assert_eq!(after_remove.tags.len(), 1); + assert!(after_remove.tags.contains_key("finance")); Ok(()) } From 2c7e564b6e297388741759aec72e890d0c9e78be Mon Sep 17 00:00:00 2001 From: Yasir Date: Wed, 18 Mar 2026 16:40:53 +0300 Subject: [PATCH 081/189] chore: fix records tags, refactor and cleanup code --- audit-trail-move/sources/audit_trail.move | 5 +- .../tests/create_audit_trail_tests.move | 2 +- audit-trail-move/tests/role_tests.move | 48 +++++++++++++++++++ audit-trail-rs/src/core/builder.rs | 6 +-- audit-trail-rs/src/core/create/operations.rs | 16 +++++-- .../src/core/create/transactions.rs | 4 +- audit-trail-rs/tests/e2e/client.rs | 2 +- audit-trail-rs/tests/e2e/trail.rs | 32 ++++++++++++- 8 files changed, 97 insertions(+), 18 deletions(-) diff --git a/audit-trail-move/sources/audit_trail.move b/audit-trail-move/sources/audit_trail.move index eca31390..0932461b 100644 --- a/audit-trail-move/sources/audit_trail.move +++ b/audit-trail-move/sources/audit_trail.move @@ -455,7 +455,7 @@ public fun delete_audit_trail( records, mut tags, locking_config: _, - roles, + roles: _roles, immutable_metadata: _, updatable_metadata: _, version: _, @@ -466,7 +466,6 @@ public fun delete_audit_trail( let (_, _) = vec_map::pop(&mut tags); }; vec_map::destroy_empty(tags); - role_map::destroy(roles); object::delete(id); event::emit(AuditTrailDeleted { trail_id, timestamp }); @@ -785,7 +784,6 @@ public fun revoke_capability( self.access_mut(), cap, capability_id, - option::none(), clock, ctx, ); @@ -846,7 +844,6 @@ public fun revoke_initial_admin_capability( self.access_mut(), cap, capability_id, - option::none(), clock, ctx, ); diff --git a/audit-trail-move/tests/create_audit_trail_tests.move b/audit-trail-move/tests/create_audit_trail_tests.move index 4778a105..d18264bd 100644 --- a/audit-trail-move/tests/create_audit_trail_tests.move +++ b/audit-trail-move/tests/create_audit_trail_tests.move @@ -16,7 +16,7 @@ use audit_trail::{ new_capability_for_address } }; -use iota::{clock, test_scenario as ts, vec_map, vec_set}; +use iota::{clock, test_scenario as ts, vec_map}; use std::string; use tf_components::timelock; diff --git a/audit-trail-move/tests/role_tests.move b/audit-trail-move/tests/role_tests.move index b9a64972..03202a35 100644 --- a/audit-trail-move/tests/role_tests.move +++ b/audit-trail-move/tests/role_tests.move @@ -324,6 +324,54 @@ fun test_delete_role_success() { ts::end(scenario); } +#[test] +#[expected_failure(abort_code = audit_trail::main::ERecordTagInUse)] +fun test_remove_record_tag_rejects_role_only_usage() { + let admin_user = @0xAD; + let mut scenario = ts::begin(admin_user); + + { + let locking_config = locking::new( + locking::window_count_based(0), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail_with_tags( + &mut scenario, + locking_config, + std::option::none(), + vector[string::utf8(b"finance")], + ); + transfer::public_transfer(admin_cap, admin_user); + }; + + ts::next_tx(&mut scenario, admin_user); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + let perms = permission::from_vec(vector[permission::add_record()]); + + trail.create_role( + &admin_cap, + string::utf8(b"TaggedRole"), + perms, + std::option::some(record_tags::new_record_tags(vector[string::utf8(b"finance")])), + &clock, + ts::ctx(&mut scenario), + ); + + trail.remove_record_tag( + &admin_cap, + string::utf8(b"finance"), + &clock, + ts::ctx(&mut scenario), + ); + + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + }; + + ts::end(scenario); +} + // ===== Error Case Tests ===== #[test] diff --git a/audit-trail-rs/src/core/builder.rs b/audit-trail-rs/src/core/builder.rs index 51469a62..862b062d 100644 --- a/audit-trail-rs/src/core/builder.rs +++ b/audit-trail-rs/src/core/builder.rs @@ -20,7 +20,7 @@ pub struct AuditTrailBuilder { pub locking_config: LockingConfig, pub trail_metadata: Option, pub updatable_metadata: Option, - pub available_record_tags: HashSet, + pub record_tags: HashSet, } impl AuditTrailBuilder { @@ -59,12 +59,12 @@ impl AuditTrailBuilder { } /// Sets the canonical list of tags that may be used on records in this trail. - pub fn with_available_record_tags(mut self, tags: I) -> Self + pub fn with_record_tags(mut self, tags: I) -> Self where I: IntoIterator, S: Into, { - self.available_record_tags = tags.into_iter().map(Into::into).collect(); + self.record_tags = tags.into_iter().map(Into::into).collect(); self } diff --git a/audit-trail-rs/src/core/create/operations.rs b/audit-trail-rs/src/core/create/operations.rs index 3590b1b7..339f5f5f 100644 --- a/audit-trail-rs/src/core/create/operations.rs +++ b/audit-trail-rs/src/core/create/operations.rs @@ -1,6 +1,8 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +use std::collections::HashSet; + use iota_interaction::ident_str; use iota_interaction::types::base_types::{IotaAddress, ObjectID}; use iota_interaction::types::programmable_transaction_builder::ProgrammableTransactionBuilder; @@ -21,7 +23,7 @@ pub(super) struct CreateTrailArgs { pub locking_config: LockingConfig, pub trail_metadata: Option, pub updatable_metadata: Option, - pub available_record_tags: Vec, + pub record_tags: HashSet, } impl CreateOps { @@ -36,7 +38,7 @@ impl CreateOps { locking_config, trail_metadata, updatable_metadata, - mut available_record_tags, + record_tags, } = args; let initial_data = initial_data.ok_or_else(|| { @@ -63,8 +65,12 @@ impl CreateOps { }; let updatable_metadata = utils::ptb_pure(&mut ptb, "updatable_metadata", updatable_metadata)?; - available_record_tags.sort(); - let available_record_tags = utils::ptb_pure(&mut ptb, "available_record_tags", available_record_tags)?; + + let record_tags = { + let mut record_tags = record_tags.into_iter().collect::>(); + record_tags.sort(); + utils::ptb_pure(&mut ptb, "record_tags", record_tags)? + }; let clock = utils::get_clock_ref(&mut ptb); let result = ptb.programmable_move_call( @@ -78,7 +84,7 @@ impl CreateOps { locking_config, trail_metadata, updatable_metadata, - available_record_tags, + record_tags, clock, ], ); diff --git a/audit-trail-rs/src/core/create/transactions.rs b/audit-trail-rs/src/core/create/transactions.rs index 676bbe01..5ed49065 100644 --- a/audit-trail-rs/src/core/create/transactions.rs +++ b/audit-trail-rs/src/core/create/transactions.rs @@ -61,7 +61,7 @@ impl CreateTrail { locking_config, trail_metadata, updatable_metadata, - available_record_tags, + record_tags, } = self.builder.clone(); let admin = admin.ok_or_else(|| { @@ -81,7 +81,7 @@ impl CreateTrail { locking_config, trail_metadata, updatable_metadata, - available_record_tags: available_record_tags.into_iter().collect(), + record_tags, }) } } diff --git a/audit-trail-rs/tests/e2e/client.rs b/audit-trail-rs/tests/e2e/client.rs index ab16622c..0a2a96a7 100644 --- a/audit-trail-rs/tests/e2e/client.rs +++ b/audit-trail-rs/tests/e2e/client.rs @@ -98,7 +98,7 @@ impl TestClient { let created = self .create_trail() .with_initial_record(data, None) - .with_available_record_tags(tags) + .with_record_tags(tags) .finish() .build_and_execute(self) .await? diff --git a/audit-trail-rs/tests/e2e/trail.rs b/audit-trail-rs/tests/e2e/trail.rs index da7bffd4..aed5f65a 100644 --- a/audit-trail-rs/tests/e2e/trail.rs +++ b/audit-trail-rs/tests/e2e/trail.rs @@ -405,7 +405,7 @@ async fn manage_record_tag_registry_roundtrip() -> anyhow::Result<()> { let created = client .create_trail() .with_initial_record(Data::text("trail-tag-registry"), None) - .with_available_record_tags(["finance"]) + .with_record_tags(["finance"]) .finish() .build_and_execute(&client) .await? @@ -436,7 +436,7 @@ async fn remove_record_tag_rejects_in_use_tag() -> anyhow::Result<()> { let created = client .create_trail() .with_initial_record(Data::text("trail-tag-in-use"), None) - .with_available_record_tags(["finance"]) + .with_record_tags(["finance"]) .finish() .build_and_execute(&client) .await? @@ -466,3 +466,31 @@ async fn remove_record_tag_rejects_in_use_tag() -> anyhow::Result<()> { Ok(()) } + +#[tokio::test] +async fn remove_record_tag_rejects_role_only_usage() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let created = client + .create_trail() + .with_initial_record(Data::text("trail-tag-role-usage"), None) + .with_record_tags(["finance"]) + .finish() + .build_and_execute(&client) + .await? + .output; + + client + .create_role( + created.trail_id, + "TaggedWriter", + vec![Permission::AddRecord], + Some(RecordTags::new(["finance"])), + ) + .await?; + + let trail = client.trail(created.trail_id); + let removed = trail.tags().remove("finance").build_and_execute(&client).await; + assert!(removed.is_err(), "role-backed tags must not be removable"); + + Ok(()) +} From 3652e2cbb84ad1be6863395578715058955546a8 Mon Sep 17 00:00:00 2001 From: Yasir Date: Wed, 18 Mar 2026 17:06:26 +0300 Subject: [PATCH 082/189] chore: bump up deps --- Cargo.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 725dda07..e024870d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,12 +17,12 @@ async-trait = "0.1" bcs = "0.1" chrono = { version = "0.4", default-features = false } hyper = "1" -iota-sdk = { git = "https://github.com/iotaledger/iota.git", package = "iota-sdk", tag = "v1.17.2" } +iota-sdk = { git = "https://github.com/iotaledger/iota.git", package = "iota-sdk", tag = "v1.18.1" } iota_interaction = { git = "https://github.com/iotaledger/product-core.git", branch = "feat/tf-compoenents-dev", default-features = false, package = "iota_interaction" } iota_interaction_rust = { git = "https://github.com/iotaledger/product-core.git", branch = "feat/tf-compoenents-dev", default-features = false, package = "iota_interaction_rust" } iota_interaction_ts = { git = "https://github.com/iotaledger/product-core.git", branch = "feat/tf-compoenents-dev", default-features = false, package = "iota_interaction_ts" } product_common = { git = "https://github.com/iotaledger/product-core.git", branch = "feat/tf-compoenents-dev", default-features = false, package = "product_common" } - +secret-storage = { git = "https://github.com/iotaledger/secret-storage.git", tag = "v0.3.0", default-features = false } serde = { version = "1.0", default-features = false, features = ["alloc", "derive"] } serde-aux = { version = "4.7.0", default-features = false } serde_json = { version = "1.0", default-features = false } From 261a68ae7398061b908578f987725f4bfac251a7 Mon Sep 17 00:00:00 2001 From: Yasir Date: Wed, 18 Mar 2026 17:09:40 +0300 Subject: [PATCH 083/189] chore: type import fixes --- examples/07_transfer_dynamic_notarization.rs | 2 +- examples/utils/utils.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/07_transfer_dynamic_notarization.rs b/examples/07_transfer_dynamic_notarization.rs index b5bbffd6..b0473665 100644 --- a/examples/07_transfer_dynamic_notarization.rs +++ b/examples/07_transfer_dynamic_notarization.rs @@ -5,7 +5,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::Result; use examples::get_funded_client; -use iota_interaction::types::base_types::IotaAddress; +use iota_sdk::types::base_types::IotaAddress; use notarization::core::types::{State, TimeLock}; #[tokio::main] diff --git a/examples/utils/utils.rs b/examples/utils/utils.rs index 388dfb1f..5f918f9c 100644 --- a/examples/utils/utils.rs +++ b/examples/utils/utils.rs @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 use anyhow::Context; -use iota_interaction::{IOTA_LOCAL_NETWORK_URL, IotaClientBuilder}; +use iota_sdk::{IOTA_LOCAL_NETWORK_URL, IotaClientBuilder}; use notarization::client::{NotarizationClient, NotarizationClientReadOnly}; use product_common::test_utils::{InMemSigner, request_funds}; From 97174f13abb337ad845e34aebada9c16ff5466bb Mon Sep 17 00:00:00 2001 From: Yasir Date: Thu, 19 Mar 2026 10:59:55 +0300 Subject: [PATCH 084/189] chore: add initial record refactor --- audit-trail-move/scripts/publish_package.sh | 2 +- audit-trail-move/sources/audit_trail.move | 16 +++--- audit-trail-move/sources/record.move | 37 +++++++++++++- .../tests/create_audit_trail_tests.move | 1 - audit-trail-move/tests/test_utils.move | 16 ++++-- audit-trail-rs/src/core/builder.rs | 23 ++++++--- audit-trail-rs/src/core/create/operations.rs | 26 +++++----- .../src/core/create/transactions.rs | 6 +-- audit-trail-rs/src/core/types/record.rs | 48 ++++++++++++++--- audit-trail-rs/src/package.rs | 2 +- audit-trail-rs/tests/e2e/client.rs | 5 +- audit-trail-rs/tests/e2e/locking.rs | 16 ++++-- audit-trail-rs/tests/e2e/records.rs | 8 +-- audit-trail-rs/tests/e2e/trail.rs | 51 ++++++++++++++----- 14 files changed, 185 insertions(+), 72 deletions(-) diff --git a/audit-trail-move/scripts/publish_package.sh b/audit-trail-move/scripts/publish_package.sh index 7fb136e3..00a87d4a 100755 --- a/audit-trail-move/scripts/publish_package.sh +++ b/audit-trail-move/scripts/publish_package.sh @@ -12,6 +12,6 @@ RESPONSE=$(iota client publish --silence-warnings --json --gas-budget 500000000 } || { # catch echo $RESPONSE } - +c export IOTA_AUDIT_TRAIL_PKG_ID=$PACKAGE_ID echo "${IOTA_AUDIT_TRAIL_PKG_ID}" diff --git a/audit-trail-move/sources/audit_trail.move b/audit-trail-move/sources/audit_trail.move index 0932461b..de1434fa 100644 --- a/audit-trail-move/sources/audit_trail.move +++ b/audit-trail-move/sources/audit_trail.move @@ -152,8 +152,7 @@ public fun new_trail_metadata(name: String, description: Option): Immuta /// roles and issue capabilities to other users. /// * Trail ID public fun create( - data: Option, - record_metadata: Option, + initial_record: Option>, locking_config: LockingConfig, trail_metadata: Option, updatable_metadata: Option, @@ -170,15 +169,12 @@ public fun create( let mut records = linked_table::new>(ctx); let mut sequence_number = 0; - if (data.is_some()) { - let record = record::new( - data.destroy_some(), - record_metadata, - option::none(), + if (initial_record.is_some()) { + let record = record::into_record( + initial_record.destroy_some(), 0, creator, timestamp, - record::new_correction(), ); linked_table::push_back(&mut records, 0, record); @@ -191,7 +187,7 @@ public fun create( timestamp, }); } else { - data.destroy_none(); + initial_record.destroy_none(); }; let role_admin_permissions = role_map::new_role_admin_permissions( @@ -320,7 +316,7 @@ public fun add_record( seq, caller, timestamp, - record::new_correction(), + record::empty(), ); linked_table::push_back(&mut self.records, seq, record); diff --git a/audit-trail-move/sources/record.move b/audit-trail-move/sources/record.move index 5d45589e..79af1e93 100644 --- a/audit-trail-move/sources/record.move +++ b/audit-trail-move/sources/record.move @@ -28,8 +28,24 @@ public struct Record has store { correction: RecordCorrection, } +/// Input used when creating a trail with an initial record. +public struct InitialRecord has copy, drop, store { + data: D, + metadata: Option, + tag: Option, +} + // ===== Constructors ===== +/// Create a new initial-record input. +public fun new_initial_record( + data: D, + metadata: Option, + tag: Option, +): InitialRecord { + InitialRecord { data, metadata, tag } +} + /// Create a new record public(package) fun new( data: D, @@ -51,6 +67,25 @@ public(package) fun new( } } +/// Convert an initial-record input into a stored record. +public(package) fun into_record( + initial_record: InitialRecord, + sequence_number: u64, + added_by: address, + added_at: u64, +): Record { + let InitialRecord { data, metadata, tag } = initial_record; + new( + data, + metadata, + tag, + sequence_number, + added_by, + added_at, + empty(), + ) +} + // ===== Getters ===== /// Get the stored data from a record @@ -108,7 +143,7 @@ public struct RecordCorrection has copy, drop, store { } /// Create a new correction tracker for a normal (non-correcting) record -public fun new_correction(): RecordCorrection { +public fun empty(): RecordCorrection { RecordCorrection { replaces: vec_set::empty(), is_replaced_by: option::none(), diff --git a/audit-trail-move/tests/create_audit_trail_tests.move b/audit-trail-move/tests/create_audit_trail_tests.move index d18264bd..ef57fe59 100644 --- a/audit-trail-move/tests/create_audit_trail_tests.move +++ b/audit-trail-move/tests/create_audit_trail_tests.move @@ -202,7 +202,6 @@ fun test_create_minimal_metadata() { ); let (admin_cap, _trail_id) = main::create( - option::none(), option::none(), locking_config, option::none(), diff --git a/audit-trail-move/tests/test_utils.move b/audit-trail-move/tests/test_utils.move index 443af919..ffc4b7f8 100644 --- a/audit-trail-move/tests/test_utils.move +++ b/audit-trail-move/tests/test_utils.move @@ -1,7 +1,7 @@ #[test_only] module audit_trail::test_utils; -use audit_trail::{locking, main::{Self, AuditTrail}}; +use audit_trail::{locking, main::{Self, AuditTrail}, record}; use iota::{clock::{Self, Clock}, test_scenario::{Self as ts, Scenario}}; use std::string; use tf_components::{capability::Capability, role_map::RoleMap}; @@ -58,9 +58,19 @@ public(package) fun setup_test_audit_trail_with_tags( option::some(string::utf8(b"Setup Test Trail Description")), ); + let initial_record = if (initial_data.is_some()) { + option::some(record::new_initial_record( + initial_data.destroy_some(), + option::none(), + option::none(), + )) + } else { + initial_data.destroy_none(); + option::none() + }; + let (admin_cap, trail_id) = main::create( - initial_data, - option::none(), + initial_record, locking_config, option::some(trail_metadata), option::none(), diff --git a/audit-trail-rs/src/core/builder.rs b/audit-trail-rs/src/core/builder.rs index 862b062d..f143c176 100644 --- a/audit-trail-rs/src/core/builder.rs +++ b/audit-trail-rs/src/core/builder.rs @@ -8,15 +8,14 @@ use std::collections::HashSet; use iota_interaction::types::base_types::IotaAddress; use product_common::transaction::transaction_builder::TransactionBuilder; -use super::types::{Data, ImmutableMetadata, LockingConfig}; +use super::types::{Data, ImmutableMetadata, InitialRecord, LockingConfig}; use crate::core::create::CreateTrail; /// Builder for creating an audit trail. #[derive(Debug, Clone, Default)] pub struct AuditTrailBuilder { pub admin: Option, - pub record: Option, - pub record_metadata: Option, + pub initial_record: Option, pub locking_config: LockingConfig, pub trail_metadata: Option, pub updatable_metadata: Option, @@ -24,10 +23,20 @@ pub struct AuditTrailBuilder { } impl AuditTrailBuilder { - /// Sets the initial record data and optional record metadata. - pub fn with_initial_record(mut self, data: impl Into, metadata: Option) -> Self { - self.record = Some(data.into()); - self.record_metadata = metadata; + /// Sets the full initial record input used during trail creation. + pub fn with_initial_record(mut self, initial_record: InitialRecord) -> Self { + self.initial_record = Some(initial_record); + self + } + + /// Convenience helper for constructing the initial record inline. + pub fn with_initial_record_parts( + mut self, + data: impl Into, + metadata: Option, + tag: Option, + ) -> Self { + self.initial_record = Some(InitialRecord::new(data, metadata, tag)); self } diff --git a/audit-trail-rs/src/core/create/operations.rs b/audit-trail-rs/src/core/create/operations.rs index 339f5f5f..c6e33484 100644 --- a/audit-trail-rs/src/core/create/operations.rs +++ b/audit-trail-rs/src/core/create/operations.rs @@ -8,7 +8,7 @@ use iota_interaction::types::base_types::{IotaAddress, ObjectID}; use iota_interaction::types::programmable_transaction_builder::ProgrammableTransactionBuilder; use iota_interaction::types::transaction::{Argument, ProgrammableTransaction}; -use crate::core::types::{Data, ImmutableMetadata, LockingConfig}; +use crate::core::types::{ImmutableMetadata, InitialRecord, LockingConfig}; use crate::core::utils; use crate::error::Error; @@ -18,8 +18,7 @@ pub(super) struct CreateTrailArgs { pub audit_trail_package_id: ObjectID, pub tf_components_package_id: ObjectID, pub admin: IotaAddress, - pub initial_data: Option, - pub initial_record_metadata: Option, + pub initial_record: Option, pub locking_config: LockingConfig, pub trail_metadata: Option, pub updatable_metadata: Option, @@ -33,23 +32,25 @@ impl CreateOps { audit_trail_package_id, tf_components_package_id, admin, - initial_data, - initial_record_metadata, + initial_record, locking_config, trail_metadata, updatable_metadata, record_tags, } = args; - let initial_data = initial_data.ok_or_else(|| { + let initial_record = initial_record.ok_or_else(|| { Error::InvalidArgument( - "initial_data is required to infer trail record type; use `with_initial_record(...)`".to_string(), + "initial_record is required to infer trail record type; use `with_initial_record(...)`".to_string(), ) })?; - let data_tag = initial_data.tag(); - let initial_data_arg = initial_data.into_option_ptb(&mut ptb, "initial_data")?; - - let initial_record_metadata = utils::ptb_pure(&mut ptb, "initial_record_metadata", initial_record_metadata)?; + let data_tag = initial_record.data.tag(); + let initial_record_tag = InitialRecord::tag(audit_trail_package_id, &data_tag); + let initial_record_arg = initial_record.into_ptb(&mut ptb, audit_trail_package_id)?; + let initial_record = + utils::option_to_move(Some(initial_record_arg), initial_record_tag, &mut ptb).map_err(|e| { + Error::InvalidArgument(format!("failed to build initial_record option: {e}")) + })?; let locking_config = locking_config.to_ptb(&mut ptb, audit_trail_package_id, tf_components_package_id)?; let immutable_metadata_tag = ImmutableMetadata::tag(audit_trail_package_id); @@ -79,8 +80,7 @@ impl CreateOps { ident_str!("create").into(), vec![data_tag], vec![ - initial_data_arg, - initial_record_metadata, + initial_record, locking_config, trail_metadata, updatable_metadata, diff --git a/audit-trail-rs/src/core/create/transactions.rs b/audit-trail-rs/src/core/create/transactions.rs index 5ed49065..4132ee33 100644 --- a/audit-trail-rs/src/core/create/transactions.rs +++ b/audit-trail-rs/src/core/create/transactions.rs @@ -56,8 +56,7 @@ impl CreateTrail { { let AuditTrailBuilder { admin, - record: data, - record_metadata, + initial_record, locking_config, trail_metadata, updatable_metadata, @@ -76,8 +75,7 @@ impl CreateTrail { audit_trail_package_id: client.package_id(), tf_components_package_id, admin, - initial_data: data, - initial_record_metadata: record_metadata, + initial_record, locking_config, trail_metadata, updatable_metadata, diff --git a/audit-trail-rs/src/core/types/record.rs b/audit-trail-rs/src/core/types/record.rs index ffefe2a9..02e6c414 100644 --- a/audit-trail-rs/src/core/types/record.rs +++ b/audit-trail-rs/src/core/types/record.rs @@ -4,6 +4,8 @@ use std::collections::{BTreeMap, HashSet}; use std::str::FromStr; +use iota_interaction::ident_str; +use iota_interaction::types::base_types::ObjectID; use iota_interaction::types::base_types::IotaAddress; use iota_interaction::types::programmable_transaction_builder::ProgrammableTransactionBuilder as Ptb; use iota_interaction::types::transaction::Argument; @@ -33,6 +35,44 @@ pub struct Record { pub correction: RecordCorrection, } +/// Input used when creating a trail with an initial record. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct InitialRecord { + pub data: D, + pub metadata: Option, + pub tag: Option, +} + +impl InitialRecord { + pub fn new(data: impl Into, metadata: Option, tag: Option) -> Self { + Self { + data: data.into(), + metadata, + tag, + } + } + + pub(crate) fn tag(package_id: ObjectID, data_tag: &TypeTag) -> TypeTag { + TypeTag::from_str(&format!("{package_id}::record::InitialRecord<{data_tag}>")) + .expect("invalid TypeTag for InitialRecord") + } + + pub(in crate::core) fn into_ptb(self, ptb: &mut Ptb, package_id: ObjectID) -> Result { + let data_tag = self.data.tag(); + let data = self.data.into_ptb(ptb, "initial_record_data")?; + let metadata = utils::ptb_pure(ptb, "initial_record_metadata", self.metadata)?; + let tag = utils::ptb_pure(ptb, "initial_record_tag", self.tag)?; + + Ok(ptb.programmable_move_call( + package_id, + ident_str!("record").into(), + ident_str!("new_initial_record").into(), + vec![data_tag], + vec![data, metadata, tag], + )) + } +} + /// Bidirectional correction tracking for audit records. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] pub struct RecordCorrection { @@ -103,14 +143,6 @@ impl Data { } } - /// Creates a PTB argument for `Option` where `D` is the concrete Move data type. - pub(in crate::core) fn into_option_ptb(self, ptb: &mut Ptb, name: &str) -> Result { - match self { - Data::Bytes(bytes) => utils::ptb_pure(ptb, name, Some(bytes)), - Data::Text(text) => utils::ptb_pure(ptb, name, Some(text)), - } - } - /// Validates that this data payload matches the on-chain trail data type. pub(in crate::core) fn ensure_matches_tag(&self, expected: &TypeTag) -> Result<(), Error> { let actual = self.tag(); diff --git a/audit-trail-rs/src/package.rs b/audit-trail-rs/src/package.rs index 7fc2021d..4b8ad04f 100644 --- a/audit-trail-rs/src/package.rs +++ b/audit-trail-rs/src/package.rs @@ -33,7 +33,7 @@ static AUDIT_TRAIL_PACKAGE_REGISTRY: LazyLock> = LazyLoc /// Hardcoded TfComponents package ID used for timelock constructors. /// /// Update this value after publishing TfComponents. -const TF_COMPONENTS_PACKAGE_ID: &str = "0xd1992b10e1e62fd934e0ea993e95d475a2e56e47c6317db033c8ab7f26477ab6"; +const TF_COMPONENTS_PACKAGE_ID: &str = "0xe49417fd544312a974abeea2bb76a1cc5e4e844dbe058a6f204fad9ae1005c01"; /// Returns a read lock to the package registry. pub(crate) async fn audit_trail_package_registry() -> PackageRegistryLock { diff --git a/audit-trail-rs/tests/e2e/client.rs b/audit-trail-rs/tests/e2e/client.rs index 0a2a96a7..be774a46 100644 --- a/audit-trail-rs/tests/e2e/client.rs +++ b/audit-trail-rs/tests/e2e/client.rs @@ -7,7 +7,8 @@ use std::sync::Arc; use audit_trails::AuditTrailClient; use audit_trails::core::types::{ - Capability, CapabilityIssueOptions, CapabilityIssued, Data, Permission, PermissionSet, RecordTags, RoleCreated, + Capability, CapabilityIssueOptions, CapabilityIssued, Data, InitialRecord, Permission, PermissionSet, RecordTags, + RoleCreated, }; use iota_interaction::types::base_types::{IotaAddress, ObjectID, ObjectRef}; use iota_interaction::types::crypto::PublicKey; @@ -97,7 +98,7 @@ impl TestClient { { let created = self .create_trail() - .with_initial_record(data, None) + .with_initial_record(InitialRecord::new(data, None, None)) .with_record_tags(tags) .finish() .build_and_execute(self) diff --git a/audit-trail-rs/tests/e2e/locking.rs b/audit-trail-rs/tests/e2e/locking.rs index c1cbdcc0..0ee41054 100644 --- a/audit-trail-rs/tests/e2e/locking.rs +++ b/audit-trail-rs/tests/e2e/locking.rs @@ -1,7 +1,9 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use audit_trails::core::types::{CapabilityIssueOptions, Data, LockingConfig, LockingWindow, Permission, TimeLock}; +use audit_trails::core::types::{ + CapabilityIssueOptions, Data, InitialRecord, LockingConfig, LockingWindow, Permission, TimeLock, +}; use iota_interaction::types::base_types::ObjectID; use crate::client::{TestClient, get_funded_test_client}; @@ -55,7 +57,11 @@ async fn update_locking_config_switches_count_to_time_based() -> anyhow::Result< let client = get_funded_test_client().await?; let trail_id = client .create_trail() - .with_initial_record(Data::text("trail-switch-count-to-time-e2e"), None) + .with_initial_record(InitialRecord::new( + Data::text("trail-switch-count-to-time-e2e"), + None, + None, + )) .with_locking_config(config_with_window(LockingWindow::CountBased { count: 3 })) .finish() .build_and_execute(&client) @@ -244,7 +250,11 @@ async fn is_record_locked_supports_count_window_and_missing_sequence() -> anyhow let client = get_funded_test_client().await?; let trail_id = client .create_trail() - .with_initial_record(Data::text("trail-locking-status-e2e"), None) + .with_initial_record(InitialRecord::new( + Data::text("trail-locking-status-e2e"), + None, + None, + )) .with_locking_config(config_with_window(LockingWindow::CountBased { count: 2 })) .finish() .build_and_execute(&client) diff --git a/audit-trail-rs/tests/e2e/records.rs b/audit-trail-rs/tests/e2e/records.rs index 3fd52fb1..0f0f502e 100644 --- a/audit-trail-rs/tests/e2e/records.rs +++ b/audit-trail-rs/tests/e2e/records.rs @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 use audit_trails::core::types::{ - CapabilityIssueOptions, Data, LockingConfig, LockingWindow, Permission, RecordTags, TimeLock, + CapabilityIssueOptions, Data, InitialRecord, LockingConfig, LockingWindow, Permission, RecordTags, TimeLock, }; use audit_trails::error::Error; use iota_interaction::types::base_types::ObjectID; @@ -283,7 +283,7 @@ async fn delete_record_fails_while_time_locked() -> anyhow::Result<()> { let client = get_funded_test_client().await?; let created = client .create_trail() - .with_initial_record(Data::text("locked"), None) + .with_initial_record(InitialRecord::new(Data::text("locked"), None, None)) .with_locking_config(config_with_window(LockingWindow::TimeBased { seconds: 3600 })) .finish() .build_and_execute(&client) @@ -346,7 +346,7 @@ async fn delete_record_fails_while_count_locked() -> anyhow::Result<()> { let client = get_funded_test_client().await?; let created = client .create_trail() - .with_initial_record(Data::text("count-locked"), None) + .with_initial_record(InitialRecord::new(Data::text("count-locked"), None, None)) .with_locking_config(config_with_window(LockingWindow::CountBased { count: 5 })) .finish() .build_and_execute(&client) @@ -369,7 +369,7 @@ async fn delete_records_batch_respects_limit_and_deletes_oldest_first() -> anyho let client = get_funded_test_client().await?; let created = client .create_trail() - .with_initial_record(Data::text("batch-initial"), None) + .with_initial_record(InitialRecord::new(Data::text("batch-initial"), None, None)) .with_locking_config(config_with_window(LockingWindow::TimeBased { seconds: 3600 })) .finish() .build_and_execute(&client) diff --git a/audit-trail-rs/tests/e2e/trail.rs b/audit-trail-rs/tests/e2e/trail.rs index aed5f65a..051e6655 100644 --- a/audit-trail-rs/tests/e2e/trail.rs +++ b/audit-trail-rs/tests/e2e/trail.rs @@ -2,7 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 use audit_trails::core::types::{ - CapabilityIssueOptions, Data, ImmutableMetadata, LockingConfig, LockingWindow, Permission, RecordTags, TimeLock, + CapabilityIssueOptions, Data, ImmutableMetadata, InitialRecord, LockingConfig, LockingWindow, Permission, + RecordTags, TimeLock, }; use iota_interaction::types::base_types::IotaAddress; use product_common::core_client::CoreClient; @@ -23,7 +24,11 @@ async fn create_trail_with_default_builder_settings() -> anyhow::Result<()> { let created = client .create_trail() - .with_initial_record(Data::text("audit-trail-create-default"), None) + .with_initial_record(InitialRecord::new( + Data::text("audit-trail-create-default"), + None, + None, + )) .finish() .build_and_execute(&client) .await? @@ -51,10 +56,11 @@ async fn create_trail_with_metadata_and_time_lock() -> anyhow::Result<()> { let created = client .create_trail() - .with_initial_record( + .with_initial_record(InitialRecord::new( Data::text("audit-trail-create-time-lock"), Some("initial record metadata".to_string()), - ) + None, + )) .with_locking_config(config_with_window(LockingWindow::TimeBased { seconds: 300 })) .with_trail_metadata(immutable_metadata.clone()) .with_updatable_metadata("updatable metadata") @@ -80,10 +86,11 @@ async fn create_trail_with_bytes_and_count_lock() -> anyhow::Result<()> { let created = client .create_trail() - .with_initial_record( + .with_initial_record(InitialRecord::new( Data::bytes(vec![0xAA, 0xBB, 0xCC, 0xDD]), Some("bytes metadata".to_string()), - ) + None, + )) .with_locking_config(config_with_window(LockingWindow::CountBased { count: 3 })) .with_trail_metadata_parts("Trail Count Lock", Some("count lock description".to_string())) .finish() @@ -109,7 +116,11 @@ async fn create_trail_with_custom_admin_address() -> anyhow::Result<()> { let created = client .create_trail() .with_admin(custom_admin) - .with_initial_record(Data::text("audit-trail-custom-admin"), None) + .with_initial_record(InitialRecord::new( + Data::text("audit-trail-custom-admin"), + None, + None, + )) .finish() .build_and_execute(&client) .await? @@ -131,7 +142,7 @@ async fn get_returns_on_chain_trail() -> anyhow::Result<()> { let created = client .create_trail() - .with_initial_record(Data::text("trail-get-e2e"), None) + .with_initial_record(InitialRecord::new(Data::text("trail-get-e2e"), None, None)) .with_trail_metadata_parts("Get Test", Some("description".into())) .with_updatable_metadata("initial updatable") .finish() @@ -163,7 +174,7 @@ async fn get_trail_without_metadata() -> anyhow::Result<()> { let created = client .create_trail() - .with_initial_record(Data::text("trail-no-meta-e2e"), None) + .with_initial_record(InitialRecord::new(Data::text("trail-no-meta-e2e"), None, None)) .finish() .build_and_execute(&client) .await? @@ -294,7 +305,11 @@ async fn update_metadata_does_not_affect_immutable_metadata() -> anyhow::Result< let created = client .create_trail() - .with_initial_record(Data::text("trail-immutable-check-e2e"), None) + .with_initial_record(InitialRecord::new( + Data::text("trail-immutable-check-e2e"), + None, + None, + )) .with_trail_metadata(immutable.clone()) .with_updatable_metadata("mutable") .finish() @@ -352,7 +367,11 @@ async fn delete_records_batch_then_delete_audit_trail_roundtrip() -> anyhow::Res let client = get_funded_test_client().await?; let created = client .create_trail() - .with_initial_record(Data::text("trail-batch-delete-e2e"), None) + .with_initial_record(InitialRecord::new( + Data::text("trail-batch-delete-e2e"), + None, + None, + )) .with_locking_config(config_with_window(LockingWindow::TimeBased { seconds: 3600 })) .finish() .build_and_execute(&client) @@ -404,7 +423,7 @@ async fn manage_record_tag_registry_roundtrip() -> anyhow::Result<()> { let created = client .create_trail() - .with_initial_record(Data::text("trail-tag-registry"), None) + .with_initial_record(InitialRecord::new(Data::text("trail-tag-registry"), None, None)) .with_record_tags(["finance"]) .finish() .build_and_execute(&client) @@ -435,7 +454,7 @@ async fn remove_record_tag_rejects_in_use_tag() -> anyhow::Result<()> { let client = get_funded_test_client().await?; let created = client .create_trail() - .with_initial_record(Data::text("trail-tag-in-use"), None) + .with_initial_record(InitialRecord::new(Data::text("trail-tag-in-use"), None, None)) .with_record_tags(["finance"]) .finish() .build_and_execute(&client) @@ -472,7 +491,11 @@ async fn remove_record_tag_rejects_role_only_usage() -> anyhow::Result<()> { let client = get_funded_test_client().await?; let created = client .create_trail() - .with_initial_record(Data::text("trail-tag-role-usage"), None) + .with_initial_record(InitialRecord::new( + Data::text("trail-tag-role-usage"), + None, + None, + )) .with_record_tags(["finance"]) .finish() .build_and_execute(&client) From b72255324ac5d4ce62e5c25dcfffc3b243180417 Mon Sep 17 00:00:00 2001 From: Yasir Date: Thu, 19 Mar 2026 16:03:49 +0300 Subject: [PATCH 085/189] Implement flexible record data for audit trails --- audit-trail-move/Move.lock | 10 +- audit-trail-move/Move.toml | 2 +- audit-trail-move/sources/record.move | 32 ++++ audit-trail-move/tests/capability_tests.move | 36 ++--- .../tests/create_audit_trail_tests.move | 15 +- audit-trail-move/tests/locking_tests.move | 67 ++++---- audit-trail-move/tests/record_tests.move | 36 ++--- audit-trail-move/tests/role_tests.move | 6 +- audit-trail-move/tests/test_utils.move | 35 +---- audit-trail-rs/src/core/create/operations.rs | 7 +- audit-trail-rs/src/core/records/operations.rs | 4 +- audit-trail-rs/src/core/types/record.rs | 143 ++++++++---------- audit-trail-rs/src/package.rs | 2 +- audit-trail-rs/tests/e2e/main.rs | 2 +- audit-trail-rs/tests/e2e/records.rs | 25 ++- 15 files changed, 212 insertions(+), 210 deletions(-) diff --git a/audit-trail-move/Move.lock b/audit-trail-move/Move.lock index 3f3869a7..4954376c 100644 --- a/audit-trail-move/Move.lock +++ b/audit-trail-move/Move.lock @@ -2,7 +2,7 @@ [move] version = 3 -manifest_digest = "BBDC635C3E5B1F977F4F12056411AADB62CD398CFCA75919B69BE3414CFC8393" +manifest_digest = "E2DCED5F45474DE4CA933A99DEAB29171001E7D699076C9B2528FA3A74FC2FCE" deps_digest = "397E6A9F7A624706DBDFEE056CE88391A15876868FD18A88504DA74EB458D697" dependencies = [ { id = "Iota", name = "Iota" }, @@ -44,7 +44,7 @@ dependencies = [ [[move.package]] id = "TfComponents" -source = { git = "https://github.com/iotaledger/product-core.git", rev = "feat/tf-compoenents-dev", subdir = "components_move" } +source = { local = "../../product-core/components_move" } dependencies = [ { id = "Iota", name = "Iota" }, @@ -61,7 +61,7 @@ flavor = "iota" [env] [env.localnet] -chain-id = "426effa0" -original-published-id = "0x4644007b931d759798353f3a1f631f690658f04e3111d8438d211033d35fdd34" -latest-published-id = "0x4644007b931d759798353f3a1f631f690658f04e3111d8438d211033d35fdd34" +chain-id = "78a386e3" +original-published-id = "0xf154e65610afbae141151a26bc624fe57af27917e8e7b1266ab4e8c43659a6ef" +latest-published-id = "0xf154e65610afbae141151a26bc624fe57af27917e8e7b1266ab4e8c43659a6ef" published-version = "1" diff --git a/audit-trail-move/Move.toml b/audit-trail-move/Move.toml index 3c6e966c..9914bbea 100644 --- a/audit-trail-move/Move.toml +++ b/audit-trail-move/Move.toml @@ -3,7 +3,7 @@ name = "audit_trail" edition = "2024.beta" [dependencies] -TfComponents = { git = "https://github.com/iotaledger/product-core.git", subdir = "components_move", rev = "feat/tf-compoenents-dev" } +TfComponents = { local = "../../product-core/components_move"} [addresses] audit_trail = "0x0" diff --git a/audit-trail-move/sources/record.move b/audit-trail-move/sources/record.move index 69ead0fd..0e3632e6 100644 --- a/audit-trail-move/sources/record.move +++ b/audit-trail-move/sources/record.move @@ -10,6 +10,38 @@ module audit_trail::record; use iota::vec_set::{Self, VecSet}; use std::string::String; +/// Flexible record payload that can store either raw bytes or text. +public enum Data has copy, drop, store { + Bytes(vector), + Text(String), +} + +/// Creates a bytes payload. +public fun new_bytes(bytes: vector): Data { + Data::Bytes(bytes) +} + +/// Creates a text payload. +public fun new_text(text: String): Data { + Data::Text(text) +} + +/// Returns the bytes payload when present. +public fun bytes(data: &Data): Option> { + match (data) { + Data::Bytes(bytes) => option::some(*bytes), + Data::Text(_) => option::none(), + } +} + +/// Returns the text payload when present. +public fun text(data: &Data): Option { + match (data) { + Data::Bytes(_) => option::none(), + Data::Text(text) => option::some(*text), + } +} + /// A single record in the audit trail public struct Record has store { /// Arbitrary data stored on-chain diff --git a/audit-trail-move/tests/capability_tests.move b/audit-trail-move/tests/capability_tests.move index 43640af9..67d677d0 100644 --- a/audit-trail-move/tests/capability_tests.move +++ b/audit-trail-move/tests/capability_tests.move @@ -4,11 +4,11 @@ module audit_trail::capability_tests; use audit_trail::{ locking, + record::{Self, Data}, main::AuditTrail, permission, test_utils::{ Self, - TestData, setup_test_audit_trail, fetch_capability_trail_and_clock, cleanup_capability_trail_and_clock @@ -85,7 +85,7 @@ fun setup_trail_with_record_admin_role(scenario: &mut Scenario, admin_user: addr ts::next_tx(scenario, admin_user); { let admin_cap = ts::take_from_sender(scenario); - let mut trail = ts::take_shared>(scenario); + let mut trail = ts::take_shared>(scenario); let clock = iota::clock::create_for_testing(ts::ctx(scenario)); let record_admin_perms = permission::record_admin_permissions(); @@ -363,7 +363,7 @@ fun test_destroy_capability() { // User1 destroys their capability ts::next_tx(&mut scenario, user1); { - let mut trail = ts::take_shared>(&scenario); + let mut trail = ts::take_shared>(&scenario); let cap1 = ts::take_from_sender(&scenario); // Verify both capabilities are tracked before destruction @@ -393,7 +393,7 @@ fun test_destroy_capability() { // Test: User2 destroys their own capability ts::next_tx(&mut scenario, user2); { - let mut trail = ts::take_shared>(&scenario); + let mut trail = ts::take_shared>(&scenario); let cap2 = ts::take_from_sender(&scenario); let cap_count_before = trail.access().issued_capabilities().size(); @@ -410,7 +410,7 @@ fun test_destroy_capability() { // Verify only admin capability remains ts::next_tx(&mut scenario, admin_user); { - let trail = ts::take_shared>(&scenario); + let trail = ts::take_shared>(&scenario); // Only the initial admin capability should remain assert!(trail.access().issued_capabilities().size() == 1, 8); @@ -503,7 +503,7 @@ fun test_capability_lifecycle() { clock.set_for_testing(test_utils::initial_time_for_testing() + 1000); - let test_data = test_utils::new_test_data(1, b"Test record"); + let test_data = record::new_text(string::utf8(b"Test record")); trail.add_record( &record_cap, test_data, @@ -518,7 +518,7 @@ fun test_capability_lifecycle() { // RecordAdmin destroys their capability ts::next_tx(&mut scenario, record_admin_user); { - let mut trail = ts::take_shared>(&scenario); + let mut trail = ts::take_shared>(&scenario); let record_cap = ts::take_from_sender(&scenario); trail.access_mut().destroy_capability(record_cap); @@ -596,7 +596,7 @@ fun test_capability_issued_to_only() { { let (record_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); - let test_data = test_utils::new_test_data(1, b"Authorized record"); + let test_data = record::new_text(string::utf8(b"Authorized record")); trail.add_record( &record_cap, test_data, @@ -619,7 +619,7 @@ fun test_capability_issued_to_only() { let (record_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); // This should fail as unauthorized_user has the wrong address - let test_data = test_utils::new_test_data(1, b"Unauthorized record"); + let test_data = record::new_text(string::utf8(b"Unauthorized record")); trail.add_record( &record_cap, test_data, @@ -709,7 +709,7 @@ fun test_revoked_capability_cannot_be_used() { trail.add_record( &user_cap, - test_utils::new_test_data(1, b"Should fail"), + record::new_text(string::utf8(b"Should fail")), std::option::none(), &clock, ts::ctx(&mut scenario), @@ -837,7 +837,7 @@ fun test_revoke_capability_permission_denied() { ts::next_tx(&mut scenario, user1); { let user1_cap = ts::take_from_sender(&scenario); - let mut trail = ts::take_shared>(&scenario); + let mut trail = ts::take_shared>(&scenario); let user2_cap = ts::take_from_address(&scenario, user2); let clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); @@ -986,7 +986,7 @@ fun test_capability_valid_from_only() { let (cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); clock.set_for_testing(test_utils::initial_time_for_testing() + 6000); - let test_data = test_utils::new_test_data(1, b"Test record after valid_from"); + let test_data = record::new_text(string::utf8(b"Test record after valid_from")); trail.add_record( &cap, test_data, @@ -1005,7 +1005,7 @@ fun test_capability_valid_from_only() { clock.set_for_testing(test_utils::initial_time_for_testing() + 1000); // This should fail as the capability is not valid yet - let test_data = test_utils::new_test_data(1, b"Test record before valid_from"); + let test_data = record::new_text(string::utf8(b"Test record before valid_from")); trail.add_record( &cap, test_data, @@ -1067,7 +1067,7 @@ fun test_capability_valid_until_only() { let (cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); clock.set_for_testing(valid_until_time_ms - 1000000); - let test_data = test_utils::new_test_data(1, b"Test record before valid_until"); + let test_data = record::new_text(string::utf8(b"Test record before valid_until")); trail.add_record( &cap, test_data, @@ -1086,7 +1086,7 @@ fun test_capability_valid_until_only() { clock.set_for_testing(valid_until_time_ms + 100000); // This should fail as the capability has expired - let test_data = test_utils::new_test_data(1, b"Test record after valid_until"); + let test_data = record::new_text(string::utf8(b"Test record after valid_until")); trail.add_record( &cap, test_data, @@ -1131,7 +1131,7 @@ fun test_capability_time_window() { let (cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); clock.set_for_testing(valid_from_time + 2500); - let test_data = test_utils::new_test_data(1, b"Test record within time window"); + let test_data = record::new_text(string::utf8(b"Test record within time window")); trail.add_record( &cap, test_data, @@ -1175,7 +1175,7 @@ fun test_capability_time_window_before_valid_from() { let (cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); clock.set_for_testing(valid_from_time_ms - 1000); - let test_data = test_utils::new_test_data(1, b"Test record before valid_from"); + let test_data = record::new_text(string::utf8(b"Test record before valid_from")); trail.add_record( &cap, test_data, @@ -1219,7 +1219,7 @@ fun test_capability_time_window_after_valid_until() { let (cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); clock.set_for_testing(valid_until_time_ms + 1000); - let test_data = test_utils::new_test_data(1, b"Test record after valid_until"); + let test_data = record::new_text(string::utf8(b"Test record after valid_until")); trail.add_record( &cap, test_data, diff --git a/audit-trail-move/tests/create_audit_trail_tests.move b/audit-trail-move/tests/create_audit_trail_tests.move index f01fa9a7..6788b82b 100644 --- a/audit-trail-move/tests/create_audit_trail_tests.move +++ b/audit-trail-move/tests/create_audit_trail_tests.move @@ -4,12 +4,11 @@ module audit_trail::create_audit_trail_tests; use audit_trail::{ locking, + record::{Self, Data}, main::{Self, AuditTrail, initial_admin_role_name}, test_utils::{ setup_test_audit_trail, - new_test_data, initial_time_for_testing, - TestData, fetch_capability_trail_and_clock, cleanup_capability_trail_and_clock } @@ -46,7 +45,7 @@ fun test_create_without_initial_record() { ts::next_tx(&mut scenario, user); { - let trail = ts::take_shared>(&scenario); + let trail = ts::take_shared>(&scenario); // Verify trail was created correctly assert!(trail.creator() == user, 2); @@ -70,7 +69,7 @@ fun test_create_with_initial_record() { timelock::none(), timelock::none(), ); // 1 day in seconds - let initial_data = new_test_data(42, b"Hello, World!"); + let initial_data = record::new_text(string::utf8(b"Hello, World!")); let (admin_cap, trail_id) = setup_test_audit_trail( &mut scenario, @@ -88,7 +87,7 @@ fun test_create_with_initial_record() { ts::next_tx(&mut scenario, user); { - let trail = ts::take_shared>(&scenario); + let trail = ts::take_shared>(&scenario); // Verify trail with initial record assert!(trail.creator() == user, 2); @@ -119,7 +118,7 @@ fun test_create_minimal_metadata() { timelock::none(), ); - let (admin_cap, _trail_id) = main::create( + let (admin_cap, _trail_id) = main::create( option::none(), option::none(), locking_config, @@ -139,7 +138,7 @@ fun test_create_minimal_metadata() { ts::next_tx(&mut scenario, user); { - let trail = ts::take_shared>(&scenario); + let trail = ts::take_shared>(&scenario); // Verify trail was created assert!(trail.creator() == user, 1); @@ -175,7 +174,7 @@ fun test_create_with_locking_enabled() { ts::next_tx(&mut scenario, user); { - let trail = ts::take_shared>(&scenario); + let trail = ts::take_shared>(&scenario); // Verify trail with locking enabled assert!(trail.creator() == user, 0); diff --git a/audit-trail-move/tests/locking_tests.move b/audit-trail-move/tests/locking_tests.move index 611ea0bc..4bf1f95d 100644 --- a/audit-trail-move/tests/locking_tests.move +++ b/audit-trail-move/tests/locking_tests.move @@ -4,13 +4,12 @@ module audit_trail::locking_tests; use audit_trail::{ locking, + record::{Self, Data}, main::{Self, AuditTrail}, permission, test_utils::{ Self, - TestData, setup_test_audit_trail, - new_test_data, initial_time_for_testing, fetch_capability_trail_and_clock, cleanup_capability_trail_and_clock, @@ -38,14 +37,14 @@ fun test_time_based_locking_within_window() { let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, - std::option::some(new_test_data(1, b"Test")), + std::option::some(record::new_text(string::utf8(b"Test"))), ); admin_cap.destroy_for_testing(); }; ts::next_tx(&mut scenario, admin); { - let trail = ts::take_shared>(&scenario); + let trail = ts::take_shared>(&scenario); let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); // 1 second after creation - locked @@ -82,14 +81,14 @@ fun test_time_based_locking_outside_window() { let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, - std::option::some(new_test_data(1, b"Test")), + std::option::some(record::new_text(string::utf8(b"Test"))), ); admin_cap.destroy_for_testing(); }; ts::next_tx(&mut scenario, admin); { - let trail = ts::take_shared>(&scenario); + let trail = ts::take_shared>(&scenario); let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); // 1 hour + 1 second after creation - unlocked @@ -162,7 +161,7 @@ fun test_count_based_locking() { ts::next_tx(&mut scenario, admin); { let record_cap = ts::take_from_sender(&scenario); - let mut trail = ts::take_shared>(&scenario); + let mut trail = ts::take_shared>(&scenario); let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); clock.set_for_testing(initial_time_for_testing() + 1000); @@ -171,7 +170,7 @@ fun test_count_based_locking() { while (i < 5) { trail.add_record( &record_cap, - new_test_data(i, b"Record"), + record::new_text(string::utf8(b"Record")), std::option::none(), &clock, ts::ctx(&mut scenario), @@ -211,14 +210,14 @@ fun test_count_based_locking_single_record() { let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, - std::option::some(new_test_data(1, b"Single")), + std::option::some(record::new_text(string::utf8(b"Single"))), ); admin_cap.destroy_for_testing(); }; ts::next_tx(&mut scenario, admin); { - let trail = ts::take_shared>(&scenario); + let trail = ts::take_shared>(&scenario); let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); clock.set_for_testing(initial_time_for_testing() + 1000); @@ -247,14 +246,14 @@ fun test_no_locking() { let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, - std::option::some(new_test_data(1, b"Test")), + std::option::some(record::new_text(string::utf8(b"Test"))), ); admin_cap.destroy_for_testing(); }; ts::next_tx(&mut scenario, admin); { - let trail = ts::take_shared>(&scenario); + let trail = ts::take_shared>(&scenario); let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); clock.set_for_testing(initial_time_for_testing()); @@ -285,7 +284,7 @@ fun test_update_locking_config() { let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, - std::option::some(new_test_data(1, b"Test")), + std::option::some(record::new_text(string::utf8(b"Test"))), ); transfer::public_transfer(admin_cap, admin); }; @@ -429,7 +428,7 @@ fun test_update_delete_record_window() { let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, - std::option::some(new_test_data(1, b"Test")), + std::option::some(record::new_text(string::utf8(b"Test"))), ); transfer::public_transfer(admin_cap, admin); }; @@ -577,7 +576,7 @@ fun test_delete_record_after_time_lock_expires() { let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, - std::option::some(new_test_data(1, b"Locked record")), + std::option::some(record::new_text(string::utf8(b"Locked record"))), ); transfer::public_transfer(admin_cap, admin); }; @@ -614,7 +613,7 @@ fun test_delete_record_after_time_lock_expires() { // Test boundary: exactly at lock expiry (should still be locked) ts::next_tx(&mut scenario, admin); { - let trail = ts::take_shared>(&scenario); + let trail = ts::take_shared>(&scenario); let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); // Exactly at 1 hour mark - record age equals time window (edge case) @@ -629,7 +628,7 @@ fun test_delete_record_after_time_lock_expires() { // Delete record after time lock expires ts::next_tx(&mut scenario, admin); { - let mut trail = ts::take_shared>(&scenario); + let mut trail = ts::take_shared>(&scenario); let record_cap = ts::take_from_sender(&scenario); // 1 hour + 1 second after creation - clearly past the lock window @@ -669,14 +668,14 @@ fun test_time_lock_boundary_just_before_expiry() { let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, - std::option::some(new_test_data(1, b"Test")), + std::option::some(record::new_text(string::utf8(b"Test"))), ); admin_cap.destroy_for_testing(); }; ts::next_tx(&mut scenario, admin); { - let trail = ts::take_shared>(&scenario); + let trail = ts::take_shared>(&scenario); let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); // 1 millisecond before lock expires - should still be locked @@ -744,7 +743,7 @@ fun test_time_based_locking_all_recent_records_locked() { while (i < 5) { trail.add_record( &record_cap, - new_test_data(i, b"Record"), + record::new_text(string::utf8(b"Record")), std::option::none(), &clock, ts::ctx(&mut scenario), @@ -759,7 +758,7 @@ fun test_time_based_locking_all_recent_records_locked() { // Test: Records locked by time-based window ts::next_tx(&mut scenario, admin); { - let trail = ts::take_shared>(&scenario); + let trail = ts::take_shared>(&scenario); let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); // Shortly after creation - all records are time-locked @@ -830,7 +829,7 @@ fun test_count_based_locking_last_records_remain_locked() { while (i < 5) { trail.add_record( &record_cap, - new_test_data(i, b"Record"), + record::new_text(string::utf8(b"Record")), std::option::none(), &clock, ts::ctx(&mut scenario), @@ -845,7 +844,7 @@ fun test_count_based_locking_last_records_remain_locked() { // Test: Count lock active for last 2 records ts::next_tx(&mut scenario, admin); { - let trail = ts::take_shared>(&scenario); + let trail = ts::take_shared>(&scenario); let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); // 2 hours later, count lock behavior should be unchanged @@ -918,7 +917,7 @@ fun test_time_based_locking_still_locked_before_expiry() { while (i < 5) { trail.add_record( &record_cap, - new_test_data(i, b"Record"), + record::new_text(string::utf8(b"Record")), std::option::none(), &clock, ts::ctx(&mut scenario), @@ -933,7 +932,7 @@ fun test_time_based_locking_still_locked_before_expiry() { // Test: Time lock still active before expiry ts::next_tx(&mut scenario, admin); { - let trail = ts::take_shared>(&scenario); + let trail = ts::take_shared>(&scenario); let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); // Only 30 minutes after creation - time lock still active @@ -1002,7 +1001,7 @@ fun test_count_based_locking_old_record_can_delete() { while (i < 5) { trail.add_record( &record_cap, - new_test_data(i, b"Record"), + record::new_text(string::utf8(b"Record")), std::option::none(), &clock, ts::ctx(&mut scenario), @@ -1018,7 +1017,7 @@ fun test_count_based_locking_old_record_can_delete() { // Test: Old record is outside count window and can be deleted ts::next_tx(&mut scenario, admin); { - let mut trail = ts::take_shared>(&scenario); + let mut trail = ts::take_shared>(&scenario); let record_cap = ts::take_from_sender(&scenario); let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); @@ -1059,7 +1058,7 @@ fun test_delete_records_batch_bypasses_record_lock() { let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, - std::option::some(new_test_data(1, b"Locked")), + std::option::some(record::new_text(string::utf8(b"Locked"))), ); transfer::public_transfer(admin_cap, admin); }; @@ -1122,7 +1121,7 @@ fun test_delete_records_batch_requires_delete_all_records_permission() { let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, - std::option::some(new_test_data(1, b"Record")), + std::option::some(record::new_text(string::utf8(b"Record"))), ); transfer::public_transfer(admin_cap, admin); }; @@ -1161,7 +1160,7 @@ fun test_delete_records_batch_requires_delete_all_records_permission() { ts::next_tx(&mut scenario, admin); { let delete_only_cap = ts::take_from_sender(&scenario); - let mut trail = ts::take_shared>(&scenario); + let mut trail = ts::take_shared>(&scenario); let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); clock.set_for_testing(initial_time_for_testing() + 1000); @@ -1190,7 +1189,7 @@ fun test_delete_audit_trail_fails_while_not_empty() { let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, - std::option::some(new_test_data(1, b"Record")), + std::option::some(record::new_text(string::utf8(b"Record"))), ); transfer::public_transfer(admin_cap, admin); }; @@ -1198,7 +1197,7 @@ fun test_delete_audit_trail_fails_while_not_empty() { ts::next_tx(&mut scenario, admin); { let admin_cap = ts::take_from_sender(&scenario); - let mut trail = ts::take_shared>(&scenario); + let mut trail = ts::take_shared>(&scenario); let clock = clock::create_for_testing(ts::ctx(&mut scenario)); let delete_trail_role = string::utf8(b"DeleteTrailOnly"); @@ -1246,7 +1245,7 @@ fun test_delete_audit_trail_after_batch_cleanup() { let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, - std::option::some(new_test_data(1, b"Record")), + std::option::some(record::new_text(string::utf8(b"Record"))), ); transfer::public_transfer(admin_cap, admin); }; @@ -1254,7 +1253,7 @@ fun test_delete_audit_trail_after_batch_cleanup() { ts::next_tx(&mut scenario, admin); { let admin_cap = ts::take_from_sender(&scenario); - let mut trail = ts::take_shared>(&scenario); + let mut trail = ts::take_shared>(&scenario); let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); let delete_maintenance_role = string::utf8(b"DeleteMaintenance"); let delete_maintenance_perms = permission::from_vec(vector[ diff --git a/audit-trail-move/tests/record_tests.move b/audit-trail-move/tests/record_tests.move index 6ca8c84f..0bcbd9be 100644 --- a/audit-trail-move/tests/record_tests.move +++ b/audit-trail-move/tests/record_tests.move @@ -4,16 +4,13 @@ module audit_trail::record_tests; use audit_trail::{ locking, + record::{Self, Data}, main::{Self, AuditTrail}, permission, test_utils::{ Self, - TestData, setup_test_audit_trail, - new_test_data, initial_time_for_testing, - test_data_value, - test_data_message, fetch_capability_trail_and_clock, cleanup_capability_trail_and_clock, cleanup_trail_and_clock @@ -87,7 +84,7 @@ fun test_add_record_to_empty_trail() { // Add record trail.add_record( &record_cap, - new_test_data(42, b"First record"), + record::new_text(string::utf8(b"First record")), std::option::some(string::utf8(b"metadata")), &clock, ts::ctx(&mut scenario), @@ -164,7 +161,7 @@ fun test_add_multiple_records() { while (i < 3) { trail.add_record( &record_cap, - new_test_data(i, b"Record"), + record::new_text(string::utf8(b"Record")), std::option::none(), &clock, ts::ctx(&mut scenario), @@ -245,7 +242,7 @@ fun test_add_record_permission_denied() { // This should fail - no AddRecord permission trail.add_record( &no_add_cap, - new_test_data(1, b"Should fail"), + record::new_text(string::utf8(b"Should fail")), std::option::none(), &clock, ts::ctx(&mut scenario), @@ -274,7 +271,7 @@ fun test_delete_record_success() { let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, - std::option::some(new_test_data(1, b"Initial")), + std::option::some(record::new_text(string::utf8(b"Initial"))), ); transfer::public_transfer(admin_cap, admin); }; @@ -348,7 +345,7 @@ fun test_delete_record_permission_denied() { let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, - std::option::some(new_test_data(1, b"Initial")), + std::option::some(record::new_text(string::utf8(b"Initial"))), ); transfer::public_transfer(admin_cap, admin); }; @@ -479,7 +476,7 @@ fun test_delete_record_time_locked() { let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, - std::option::some(new_test_data(1, b"Locked record")), + std::option::some(record::new_text(string::utf8(b"Locked record"))), ); transfer::public_transfer(admin_cap, admin); }; @@ -545,7 +542,7 @@ fun test_delete_record_count_locked() { let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, - std::option::some(new_test_data(1, b"Locked record")), + std::option::some(record::new_text(string::utf8(b"Locked record"))), ); transfer::public_transfer(admin_cap, admin); }; @@ -608,7 +605,7 @@ fun test_get_record() { timelock::none(), timelock::none(), ); - let initial_data = new_test_data(42, b"Test data"); + let initial_data = record::new_bytes(b"Test data"); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, @@ -619,13 +616,12 @@ fun test_get_record() { ts::next_tx(&mut scenario, admin); { - let trail = ts::take_shared>(&scenario); + let trail = ts::take_shared>(&scenario); let record = trail.get_record(0); let data = audit_trail::record::data(record); - assert!(data.test_data_value() == 42, 0); - assert!(data.test_data_message() == b"Test data", 1); + assert!(record::bytes(data) == option::some(b"Test data"), 0); ts::return_shared(trail); }; @@ -656,7 +652,7 @@ fun test_get_record_not_found() { ts::next_tx(&mut scenario, admin); { - let trail = ts::take_shared>(&scenario); + let trail = ts::take_shared>(&scenario); // This should fail - no records exist let _record = trail.get_record(0); @@ -720,7 +716,7 @@ fun test_first_last_sequence() { // Add first record trail.add_record( &record_cap, - new_test_data(1, b"First"), + record::new_text(string::utf8(b"First")), std::option::none(), &clock, ts::ctx(&mut scenario), @@ -732,7 +728,7 @@ fun test_first_last_sequence() { // Add second record trail.add_record( &record_cap, - new_test_data(2, b"Second"), + record::new_text(string::utf8(b"Second")), std::option::none(), &clock, ts::ctx(&mut scenario), @@ -744,7 +740,7 @@ fun test_first_last_sequence() { // Add third record trail.add_record( &record_cap, - new_test_data(3, b"Third"), + record::new_text(string::utf8(b"Third")), std::option::none(), &clock, ts::ctx(&mut scenario), @@ -783,7 +779,7 @@ fun test_is_record_locked_not_found() { ts::next_tx(&mut scenario, admin); { - let trail = ts::take_shared>(&scenario); + let trail = ts::take_shared>(&scenario); let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); clock.set_for_testing(initial_time_for_testing() + 1000); diff --git a/audit-trail-move/tests/role_tests.move b/audit-trail-move/tests/role_tests.move index 688bae38..16951974 100644 --- a/audit-trail-move/tests/role_tests.move +++ b/audit-trail-move/tests/role_tests.move @@ -4,11 +4,11 @@ module audit_trail::role_tests; use audit_trail::{ locking, + record::{Self, Data}, main::{initial_admin_role_name, AuditTrail}, permission, test_utils::{ Self, - TestData, setup_test_audit_trail, fetch_capability_trail_and_clock, cleanup_capability_trail_and_clock @@ -195,7 +195,7 @@ fun test_role_based_permission_delegation() { // Verify initial record count let initial_record_count = trail.records().length(); - let test_data = test_utils::new_test_data(42, b"Test record added by RecordAdmin"); + let test_data = record::new_text(string::utf8(b"Test record added by RecordAdmin")); trail.add_record( &record_admin_cap, @@ -556,7 +556,7 @@ fun test_get_role_permissions_nonexistent() { ts::next_tx(&mut scenario, admin_user); { - let trail = ts::take_shared>(&scenario); + let trail = ts::take_shared>(&scenario); // This should fail - role doesn't exist let _perms = trail.access().get_role_permissions(&string::utf8(b"NonExistentRole")); diff --git a/audit-trail-move/tests/test_utils.move b/audit-trail-move/tests/test_utils.move index 696dbe12..174af876 100644 --- a/audit-trail-move/tests/test_utils.move +++ b/audit-trail-move/tests/test_utils.move @@ -1,34 +1,13 @@ #[test_only] module audit_trail::test_utils; -use audit_trail::{locking, main::{Self, AuditTrail}}; +use audit_trail::{locking, main::{Self, AuditTrail}, record::Data}; use iota::{clock::{Self, Clock}, test_scenario::{Self as ts, Scenario}}; use std::string; use tf_components::{capability::Capability, role_map::RoleMap}; const INITIAL_TIME_FOR_TESTING: u64 = 1234567; -/// Test data type for audit trail records -public struct TestData has copy, drop, store { - value: u64, - message: vector, -} - -public(package) fun new_test_data(value: u64, message: vector): TestData { - TestData { - value, - message, - } -} - -public(package) fun test_data_value(data: &TestData): u64 { - data.value -} - -public(package) fun test_data_message(data: &TestData): vector { - data.message -} - public(package) fun initial_time_for_testing(): u64 { INITIAL_TIME_FOR_TESTING } @@ -37,7 +16,7 @@ public(package) fun initial_time_for_testing(): u64 { public(package) fun setup_test_audit_trail( scenario: &mut Scenario, locking_config: locking::LockingConfig, - initial_data: Option, + initial_data: Option, ): (Capability, iota::object::ID) { let (admin_cap, trail_id) = { let mut clock = clock::create_for_testing(ts::ctx(scenario)); @@ -48,7 +27,7 @@ public(package) fun setup_test_audit_trail( option::some(string::utf8(b"Setup Test Trail Description")), ); - let (admin_cap, trail_id) = main::create( + let (admin_cap, trail_id) = main::create( initial_data, option::none(), locking_config, @@ -153,9 +132,9 @@ public fun new_capability_for_address( public(package) fun fetch_capability_trail_and_clock( scenario: &mut Scenario, -): (Capability, AuditTrail, Clock) { +): (Capability, AuditTrail, Clock) { let admin_cap = ts::take_from_sender(scenario); - let trail = ts::take_shared>(scenario); + let trail = ts::take_shared>(scenario); let clock = iota::clock::create_for_testing(ts::ctx(scenario)); (admin_cap, trail, clock) } @@ -163,7 +142,7 @@ public(package) fun fetch_capability_trail_and_clock( public(package) fun cleanup_capability_trail_and_clock( scenario: &Scenario, cap: Capability, - trail: AuditTrail, + trail: AuditTrail, clock: Clock, ) { iota::clock::destroy_for_testing(clock); @@ -171,7 +150,7 @@ public(package) fun cleanup_capability_trail_and_clock( ts::return_shared(trail); } -public(package) fun cleanup_trail_and_clock(trail: AuditTrail, clock: Clock) { +public(package) fun cleanup_trail_and_clock(trail: AuditTrail, clock: Clock) { iota::clock::destroy_for_testing(clock); ts::return_shared(trail); } diff --git a/audit-trail-rs/src/core/create/operations.rs b/audit-trail-rs/src/core/create/operations.rs index 9fd813d8..99d981e8 100644 --- a/audit-trail-rs/src/core/create/operations.rs +++ b/audit-trail-rs/src/core/create/operations.rs @@ -27,11 +27,12 @@ impl CreateOps { let initial_data = initial_data.ok_or_else(|| { Error::InvalidArgument( - "initial_data is required to infer trail record type; use `with_initial_record(...)`".to_string(), + "initial_data is required to create the default flexible trail; use `with_initial_record(...)`" + .to_string(), ) })?; - let data_tag = initial_data.tag(); - let initial_data_arg = initial_data.to_option_ptb(&mut ptb, "initial_data")?; + let data_tag = Data::tag(audit_trail_package_id); + let initial_data_arg = initial_data.to_option_ptb(&mut ptb, audit_trail_package_id)?; let initial_record_metadata = utils::ptb_pure(&mut ptb, "initial_record_metadata", initial_record_metadata)?; let locking_config = locking_config.to_ptb(&mut ptb, audit_trail_package_id, tf_components_package_id)?; diff --git a/audit-trail-rs/src/core/records/operations.rs b/audit-trail-rs/src/core/records/operations.rs index d2efc31c..b5cc859b 100644 --- a/audit-trail-rs/src/core/records/operations.rs +++ b/audit-trail-rs/src/core/records/operations.rs @@ -30,9 +30,9 @@ impl RecordsOps { Permission::AddRecord, "add_record", |ptb, trail_tag| { - data.ensure_matches_tag(trail_tag)?; + Data::ensure_supported_trail_tag(trail_tag, client.package_id())?; - let data_arg = data.to_ptb(ptb, "stored_data")?; + let data_arg = data.to_ptb(ptb, client.package_id())?; let metadata = utils::ptb_pure(ptb, "record_metadata", record_metadata)?; let clock = utils::get_clock_ref(ptb); Ok(vec![data_arg, metadata, clock]) diff --git a/audit-trail-rs/src/core/types/record.rs b/audit-trail-rs/src/core/types/record.rs index 2c6392a8..647a64e1 100644 --- a/audit-trail-rs/src/core/types/record.rs +++ b/audit-trail-rs/src/core/types/record.rs @@ -4,11 +4,13 @@ use std::collections::{BTreeMap, HashSet}; use std::str::FromStr; +use iota_interaction::ident_str; +use iota_interaction::types::TypeTag; use iota_interaction::types::base_types::IotaAddress; +use iota_interaction::types::base_types::ObjectID; use iota_interaction::types::programmable_transaction_builder::ProgrammableTransactionBuilder as Ptb; use iota_interaction::types::transaction::Argument; -use iota_interaction::types::{MOVE_STDLIB_PACKAGE_ID, TypeTag}; -use serde::{Deserialize, Deserializer, Serialize}; +use serde::{Deserialize, Serialize}; use crate::core::utils; use crate::error::Error; @@ -57,69 +59,60 @@ impl RecordCorrection { } /// Supported record data types. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum Data { Bytes(Vec), Text(String), } -impl<'de> Deserialize<'de> for Data { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - // Handle both raw bytes and string representations from BCS - let bytes = Vec::::deserialize(deserializer)?; - - if let Ok(text) = String::from_utf8(bytes.clone()) { - // Additional check: if it looks like actual text (not just valid UTF-8 bytes) - if text.chars().all(|c| c.is_ascii_graphic() || c.is_ascii_whitespace()) { - Ok(Data::Text(text)) - } else { - Ok(Data::Bytes(bytes)) - } - } else { - Ok(Data::Bytes(bytes)) - } - } -} - impl Data { - /// Returns the Move type tag for this data type. - pub(crate) fn tag(&self) -> TypeTag { - match self { - Data::Bytes(_) => TypeTag::Vector(Box::new(TypeTag::U8)), - Data::Text(_) => TypeTag::from_str(&format!("{MOVE_STDLIB_PACKAGE_ID}::string::String")) - .expect("should be valid type tag"), - } + pub(crate) fn tag(package_id: ObjectID) -> TypeTag { + TypeTag::from_str(&format!("{package_id}::record::Data")).expect("should be valid type tag") } - /// Creates a PTB argument for `D` where `D` is the concrete Move data type. - pub(in crate::core) fn to_ptb(self, ptb: &mut Ptb, name: &str) -> Result { + /// Creates a PTB argument for the default flexible Move `record::Data` type. + pub(in crate::core) fn to_ptb(self, ptb: &mut Ptb, package_id: ObjectID) -> Result { match self { - Data::Bytes(bytes) => utils::ptb_pure(ptb, name, bytes), - Data::Text(text) => utils::ptb_pure(ptb, name, text), + Data::Bytes(bytes) => { + let bytes = utils::ptb_pure(ptb, "bytes", bytes)?; + Ok(ptb.programmable_move_call( + package_id, + ident_str!("record").into(), + ident_str!("new_bytes").into(), + vec![], + vec![bytes], + )) + } + Data::Text(text) => { + let text = utils::ptb_pure(ptb, "text", text)?; + Ok(ptb.programmable_move_call( + package_id, + ident_str!("record").into(), + ident_str!("new_text").into(), + vec![], + vec![text], + )) + } } } - /// Creates a PTB argument for `Option` where `D` is the concrete Move data type. - pub(in crate::core) fn to_option_ptb(self, ptb: &mut Ptb, name: &str) -> Result { - match self { - Data::Bytes(bytes) => utils::ptb_pure(ptb, name, Some(bytes)), - Data::Text(text) => utils::ptb_pure(ptb, name, Some(text)), - } + /// Creates a PTB argument for `Option`. + pub(in crate::core) fn to_option_ptb(self, ptb: &mut Ptb, package_id: ObjectID) -> Result { + let data = self.to_ptb(ptb, package_id)?; + utils::option_to_move(Some(data), Self::tag(package_id), ptb) + .map_err(|e| Error::InvalidArgument(format!("failed to build record data option: {e}"))) } - /// Validates that this data payload matches the on-chain trail data type. - pub(in crate::core) fn ensure_matches_tag(&self, expected: &TypeTag) -> Result<(), Error> { - let actual = self.tag(); + /// Validates that the on-chain trail stores the default flexible Move `record::Data` type. + pub(in crate::core) fn ensure_supported_trail_tag(expected: &TypeTag, package_id: ObjectID) -> Result<(), Error> { + let supported = Self::tag(package_id); - if &actual == expected { + if expected == &supported { Ok(()) } else { Err(Error::InvalidArgument(format!( - "record data type mismatch: provided {:?}, trail expects {:?}", - actual, expected + "unsupported trail record type {expected:?}; expected {:?}", + supported ))) } } @@ -186,52 +179,46 @@ impl From<&[u8]> for Data { #[cfg(test)] mod tests { use super::Data; + use iota_interaction::types::TypeTag; + use iota_interaction::types::base_types::ObjectID; + use std::str::FromStr; - fn deserialize_from_raw_bytes(payload: Vec) -> Data { - let encoded = bcs::to_bytes(&payload).expect("failed to bcs encode bytes payload"); + fn roundtrip(value: &Data) -> Data { + let encoded = bcs::to_bytes(value).expect("failed to bcs encode Data"); bcs::from_bytes::(&encoded).expect("failed to deserialize Data from bcs payload") } #[test] - fn deserialize_ascii_text_returns_text_variant() { - let data = deserialize_from_raw_bytes(b"hello world".to_vec()); + fn deserialize_text_variant_roundtrips() { + let data = roundtrip(&Data::Text("hello world".to_string())); assert_eq!(data, Data::Text("hello world".to_string())); } #[test] - fn deserialize_ascii_text_with_whitespace_returns_text_variant() { - let data = deserialize_from_raw_bytes(b"line 1\nline 2\tend".to_vec()); - assert_eq!(data, Data::Text("line 1\nline 2\tend".to_string())); - } - - #[test] - fn deserialize_non_ascii_utf8_returns_bytes_variant() { - let data = deserialize_from_raw_bytes("olá mundo".as_bytes().to_vec()); - assert_eq!(data, Data::Bytes("olá mundo".as_bytes().to_vec())); - } - - #[test] - fn deserialize_ascii_like_binary_returns_text_variant() { - // Demonstrates current heuristic limitation: printable ASCII payloads are interpreted as text. - let data = deserialize_from_raw_bytes(b"GIF89a".to_vec()); - assert_eq!(data, Data::Text("GIF89a".to_string())); + fn deserialize_bytes_variant_roundtrips() { + let data = roundtrip(&Data::Bytes(vec![0xF0, 0x28, 0x8C, 0x28])); + assert_eq!(data, Data::Bytes(vec![0xF0, 0x28, 0x8C, 0x28])); } #[test] - fn deserialize_utf8_with_control_chars_returns_bytes_variant() { - let data = deserialize_from_raw_bytes(vec![b'a', b'b', 0x00, b'c']); - assert_eq!(data, Data::Bytes(vec![b'a', b'b', 0x00, b'c'])); - } + fn supported_trail_tag_accepts_record_data() { + let package_id = ObjectID::from_str("0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef") + .expect("valid object id"); - #[test] - fn deserialize_invalid_utf8_returns_bytes_variant() { - let data = deserialize_from_raw_bytes(vec![0xF0, 0x28, 0x8C, 0x28]); - assert_eq!(data, Data::Bytes(vec![0xF0, 0x28, 0x8C, 0x28])); + let expected = Data::tag(package_id); + Data::ensure_supported_trail_tag(&expected, package_id).expect("record::Data should be supported"); } #[test] - fn deserialize_empty_payload_returns_empty_text() { - let data = deserialize_from_raw_bytes(Vec::new()); - assert_eq!(data, Data::Text(String::new())); + fn supported_trail_tag_rejects_legacy_string_trails() { + let package_id = ObjectID::from_str("0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef") + .expect("valid object id"); + let legacy_string = TypeTag::from_str("0x1::string::String").expect("valid string type tag"); + + let err = Data::ensure_supported_trail_tag(&legacy_string, package_id).expect_err("legacy tag should fail"); + assert!( + err.to_string().contains("unsupported trail record type"), + "unexpected error: {err}" + ); } } diff --git a/audit-trail-rs/src/package.rs b/audit-trail-rs/src/package.rs index f21f5d58..638fce95 100644 --- a/audit-trail-rs/src/package.rs +++ b/audit-trail-rs/src/package.rs @@ -34,7 +34,7 @@ static AUDIT_TRAIL_PACKAGE_REGISTRY: LazyLock> = LazyLoc /// /// Update this value after publishing TfComponents. /// TODO:Replac this with real value -const TF_COMPONENTS_PACKAGE_ID: &str = "0x5deb1782f8f078d7d85640099466c6513bee3ac261555fb06cb0bbe1f838ab17"; +const TF_COMPONENTS_PACKAGE_ID: &str = "0xefd478a0cca3cc660a46d2b55586fe799c71b331d939ee854272bc24ac16c07f"; /// Returns a read lock to the package registry. pub(crate) async fn audit_trail_package_registry() -> PackageRegistryLock { diff --git a/audit-trail-rs/tests/e2e/main.rs b/audit-trail-rs/tests/e2e/main.rs index a5aa07f1..4efcccd4 100644 --- a/audit-trail-rs/tests/e2e/main.rs +++ b/audit-trail-rs/tests/e2e/main.rs @@ -3,8 +3,8 @@ // Rust tests for Audit Trails have been temporarily deactivated during development. // Uncomment the following modules to re-enable them. +// mod access; // mod client; // mod locking; // mod records; -// mod access; // mod trail; diff --git a/audit-trail-rs/tests/e2e/records.rs b/audit-trail-rs/tests/e2e/records.rs index 29be4dfe..98ff7bff 100644 --- a/audit-trail-rs/tests/e2e/records.rs +++ b/audit-trail-rs/tests/e2e/records.rs @@ -28,6 +28,13 @@ fn assert_text_data(data: Data, expected: &str) { } } +fn assert_bytes_data(data: Data, expected: &[u8]) { + match data { + Data::Bytes(actual) => assert_eq!(actual, expected), + other => panic!("expected bytes data, got {other:?}"), + } +} + fn config_with_window(delete_record_window: LockingWindow) -> LockingConfig { LockingConfig { delete_record_window, @@ -68,23 +75,25 @@ async fn add_and_fetch_record_roundtrip() -> anyhow::Result<()> { } #[tokio::test] -async fn add_record_rejects_mismatched_data_type() -> anyhow::Result<()> { +async fn add_record_accepts_mixed_data_variants_in_default_trail() -> anyhow::Result<()> { let client = get_funded_test_client().await?; let trail_id = client.create_test_trail(Data::text("text-trail")).await?; let records = client.trail(trail_id).records(); grant_role_capability(&client, trail_id, "RecordWriter", [Permission::AddRecord]).await?; - let add_mismatch = records + let added = records .add(Data::bytes(vec![0xFF, 0x00, 0xAA]), Some("binary payload".to_string())) .build_and_execute(&client) - .await; + .await? + .output; - assert!( - add_mismatch.is_err(), - "adding bytes to a text trail should fail before execution" - ); - assert_eq!(records.record_count().await?, 1); + assert_eq!(added.sequence_number, 1); + assert_eq!(records.record_count().await?, 2); + + let record = records.get(1).await?; + assert_eq!(record.metadata, Some("binary payload".to_string())); + assert_bytes_data(record.data, &[0xFF, 0x00, 0xAA]); Ok(()) } From 12e1201fe267d2f6a3039a20256fcc334214fc67 Mon Sep 17 00:00:00 2001 From: Yasir Date: Thu, 19 Mar 2026 17:00:24 +0300 Subject: [PATCH 086/189] Allow creating empty Data trails --- audit-trail-rs/src/core/create/operations.rs | 12 ++--- audit-trail-rs/src/core/types/record.rs | 51 +------------------- audit-trail-rs/tests/e2e/records.rs | 26 +++++++++- audit-trail-rs/tests/e2e/trail.rs | 19 ++++++++ 4 files changed, 51 insertions(+), 57 deletions(-) diff --git a/audit-trail-rs/src/core/create/operations.rs b/audit-trail-rs/src/core/create/operations.rs index 99d981e8..3b7da516 100644 --- a/audit-trail-rs/src/core/create/operations.rs +++ b/audit-trail-rs/src/core/create/operations.rs @@ -25,14 +25,12 @@ impl CreateOps { ) -> Result { let mut ptb = ProgrammableTransactionBuilder::new(); - let initial_data = initial_data.ok_or_else(|| { - Error::InvalidArgument( - "initial_data is required to create the default flexible trail; use `with_initial_record(...)`" - .to_string(), - ) - })?; let data_tag = Data::tag(audit_trail_package_id); - let initial_data_arg = initial_data.to_option_ptb(&mut ptb, audit_trail_package_id)?; + let initial_data_arg = match initial_data { + Some(data) => data.to_option_ptb(&mut ptb, audit_trail_package_id)?, + None => utils::option_to_move(None, data_tag.clone(), &mut ptb) + .map_err(|e| Error::InvalidArgument(format!("failed to build initial_data option: {e}")))?, + }; let initial_record_metadata = utils::ptb_pure(&mut ptb, "initial_record_metadata", initial_record_metadata)?; let locking_config = locking_config.to_ptb(&mut ptb, audit_trail_package_id, tf_components_package_id)?; diff --git a/audit-trail-rs/src/core/types/record.rs b/audit-trail-rs/src/core/types/record.rs index 647a64e1..404a0128 100644 --- a/audit-trail-rs/src/core/types/record.rs +++ b/audit-trail-rs/src/core/types/record.rs @@ -70,7 +70,7 @@ impl Data { TypeTag::from_str(&format!("{package_id}::record::Data")).expect("should be valid type tag") } - /// Creates a PTB argument for the default flexible Move `record::Data` type. + /// Creates a PTB argument for the Move `record::Data` type exposed by the Rust SDK. pub(in crate::core) fn to_ptb(self, ptb: &mut Ptb, package_id: ObjectID) -> Result { match self { Data::Bytes(bytes) => { @@ -103,7 +103,7 @@ impl Data { .map_err(|e| Error::InvalidArgument(format!("failed to build record data option: {e}"))) } - /// Validates that the on-chain trail stores the default flexible Move `record::Data` type. + /// Validates that the on-chain trail stores the Move `record::Data` type supported by the Rust SDK. pub(in crate::core) fn ensure_supported_trail_tag(expected: &TypeTag, package_id: ObjectID) -> Result<(), Error> { let supported = Self::tag(package_id); @@ -175,50 +175,3 @@ impl From<&[u8]> for Data { Data::Bytes(value.to_vec()) } } - -#[cfg(test)] -mod tests { - use super::Data; - use iota_interaction::types::TypeTag; - use iota_interaction::types::base_types::ObjectID; - use std::str::FromStr; - - fn roundtrip(value: &Data) -> Data { - let encoded = bcs::to_bytes(value).expect("failed to bcs encode Data"); - bcs::from_bytes::(&encoded).expect("failed to deserialize Data from bcs payload") - } - - #[test] - fn deserialize_text_variant_roundtrips() { - let data = roundtrip(&Data::Text("hello world".to_string())); - assert_eq!(data, Data::Text("hello world".to_string())); - } - - #[test] - fn deserialize_bytes_variant_roundtrips() { - let data = roundtrip(&Data::Bytes(vec![0xF0, 0x28, 0x8C, 0x28])); - assert_eq!(data, Data::Bytes(vec![0xF0, 0x28, 0x8C, 0x28])); - } - - #[test] - fn supported_trail_tag_accepts_record_data() { - let package_id = ObjectID::from_str("0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef") - .expect("valid object id"); - - let expected = Data::tag(package_id); - Data::ensure_supported_trail_tag(&expected, package_id).expect("record::Data should be supported"); - } - - #[test] - fn supported_trail_tag_rejects_legacy_string_trails() { - let package_id = ObjectID::from_str("0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef") - .expect("valid object id"); - let legacy_string = TypeTag::from_str("0x1::string::String").expect("valid string type tag"); - - let err = Data::ensure_supported_trail_tag(&legacy_string, package_id).expect_err("legacy tag should fail"); - assert!( - err.to_string().contains("unsupported trail record type"), - "unexpected error: {err}" - ); - } -} diff --git a/audit-trail-rs/tests/e2e/records.rs b/audit-trail-rs/tests/e2e/records.rs index 98ff7bff..97f4a9a7 100644 --- a/audit-trail-rs/tests/e2e/records.rs +++ b/audit-trail-rs/tests/e2e/records.rs @@ -75,7 +75,7 @@ async fn add_and_fetch_record_roundtrip() -> anyhow::Result<()> { } #[tokio::test] -async fn add_record_accepts_mixed_data_variants_in_default_trail() -> anyhow::Result<()> { +async fn add_record_accepts_text_and_bytes_variants() -> anyhow::Result<()> { let client = get_funded_test_client().await?; let trail_id = client.create_test_trail(Data::text("text-trail")).await?; let records = client.trail(trail_id).records(); @@ -98,6 +98,30 @@ async fn add_record_accepts_mixed_data_variants_in_default_trail() -> anyhow::Re Ok(()) } +#[tokio::test] +async fn add_record_to_empty_trail_created_without_initial_record() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client.create_trail().finish().build_and_execute(&client).await?.output.trail_id; + let records = client.trail(trail_id).records(); + + grant_role_capability(&client, trail_id, "RecordWriter", [Permission::AddRecord]).await?; + + let added = records + .add(Data::text("first record"), Some("first metadata".to_string())) + .build_and_execute(&client) + .await? + .output; + + assert_eq!(added.sequence_number, 0); + assert_eq!(records.record_count().await?, 1); + + let record = records.get(0).await?; + assert_eq!(record.metadata, Some("first metadata".to_string())); + assert_text_data(record.data, "first record"); + + Ok(()) +} + #[tokio::test] async fn get_missing_record_fails() -> anyhow::Result<()> { let client = get_funded_test_client().await?; diff --git a/audit-trail-rs/tests/e2e/trail.rs b/audit-trail-rs/tests/e2e/trail.rs index d84a6448..f0c895d7 100644 --- a/audit-trail-rs/tests/e2e/trail.rs +++ b/audit-trail-rs/tests/e2e/trail.rs @@ -42,6 +42,25 @@ async fn create_trail_with_default_builder_settings() -> anyhow::Result<()> { Ok(()) } +#[tokio::test] +async fn create_empty_trail_without_initial_record() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + + let created = client.create_trail().finish().build_and_execute(&client).await?.output; + + assert_eq!(created.creator, client.sender_address()); + + let on_chain = created.fetch_audit_trail(&client).await?; + assert_eq!(on_chain.id.object_id(), &created.trail_id); + assert_eq!(on_chain.creator, client.sender_address()); + assert_eq!(on_chain.sequence_number, 0); + assert_eq!(on_chain.locking_config, config_with_window(LockingWindow::None)); + assert!(on_chain.immutable_metadata.is_none()); + assert!(on_chain.updatable_metadata.is_none()); + + Ok(()) +} + #[tokio::test] async fn create_trail_with_metadata_and_time_lock() -> anyhow::Result<()> { let client = get_funded_test_client().await?; From 0daa157527a1c315517a5046c7cc1958a5181a33 Mon Sep 17 00:00:00 2001 From: Christof Gerritsma Date: Thu, 19 Mar 2026 17:38:20 +0100 Subject: [PATCH 087/189] Feat: Audit Trails using `revoked_capabilities` (#216) Use the RoleMap with `revoked_capabilities` denylist --- audit-trail-move/sources/audit_trail.move | 303 +++++++++++-------- audit-trail-move/sources/record.move | 28 +- audit-trail-move/tests/capability_tests.move | 126 +++----- audit-trail-move/tests/metadata_tests.move | 3 +- 4 files changed, 241 insertions(+), 219 deletions(-) diff --git a/audit-trail-move/sources/audit_trail.move b/audit-trail-move/sources/audit_trail.move index 3da0cd22..ecd08b26 100644 --- a/audit-trail-move/sources/audit_trail.move +++ b/audit-trail-move/sources/audit_trail.move @@ -20,7 +20,12 @@ use audit_trail::{ permission::{Self, Permission}, record::{Self, Record} }; -use iota::{clock::{Self, Clock}, event, linked_table::{Self, LinkedTable}, vec_set::VecSet}; +use iota::{ + clock::{Self, Clock}, + event, + linked_table::{Self, LinkedTable}, + vec_set::VecSet +}; use std::string::String; use tf_components::{capability::Capability, role_map::{Self, RoleMap}, timelock::TimeLock}; @@ -232,13 +237,13 @@ public fun initial_admin_role_name(): String { /// Migrate the trail to the latest package version entry fun migrate( - trail: &mut AuditTrail, + self: &mut AuditTrail, cap: &Capability, clock: &Clock, ctx: &TxContext, ) { - assert!(trail.version < PACKAGE_VERSION, EPackageVersionMismatch); - trail + assert!(self.version < PACKAGE_VERSION, EPackageVersionMismatch); + self .roles .assert_capability_valid( cap, @@ -246,7 +251,7 @@ entry fun migrate( clock, ctx, ); - trail.version = PACKAGE_VERSION; + self.version = PACKAGE_VERSION; } public fun new_record_tags( @@ -263,15 +268,15 @@ public fun new_record_tags( /// /// Records are added sequentially with auto-assigned sequence numbers. public fun add_record( - trail: &mut AuditTrail, + self: &mut AuditTrail, cap: &Capability, stored_data: D, record_metadata: Option, clock: &Clock, ctx: &mut TxContext, ) { - assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); - trail + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + self .roles .assert_capability_valid( cap, @@ -279,12 +284,12 @@ public fun add_record( clock, ctx, ); - assert!(!locking::is_write_locked(&trail.locking_config, clock), ETrailWriteLocked); + assert!(!locking::is_write_locked(&self.locking_config, clock), ETrailWriteLocked); let caller = ctx.sender(); let timestamp = clock::timestamp_ms(clock); - let trail_id = trail.id(); - let seq = trail.sequence_number; + let trail_id = self.id(); + let seq = self.sequence_number; let record = record::new( stored_data, @@ -295,8 +300,8 @@ public fun add_record( record::new_correction(), ); - linked_table::push_back(&mut trail.records, seq, record); - trail.sequence_number = trail.sequence_number + 1; + linked_table::push_back(&mut self.records, seq, record); + self.sequence_number = self.sequence_number + 1; event::emit(RecordAdded { trail_id, @@ -311,14 +316,14 @@ public fun add_record( /// The record must not be locked (based on the trail's locking configuration). /// Requires the DeleteRecord permission. public fun delete_record( - trail: &mut AuditTrail, + self: &mut AuditTrail, cap: &Capability, sequence_number: u64, clock: &Clock, ctx: &mut TxContext, ) { - assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); - trail + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + self .roles .assert_capability_valid( cap, @@ -326,14 +331,14 @@ public fun delete_record( clock, ctx, ); - assert!(linked_table::contains(&trail.records, sequence_number), ERecordNotFound); - assert!(!trail.is_record_locked(sequence_number, clock), ERecordLocked); + assert!(linked_table::contains(&self.records, sequence_number), ERecordNotFound); + assert!(!self.is_record_locked(sequence_number, clock), ERecordLocked); let caller = ctx.sender(); let timestamp = clock::timestamp_ms(clock); - let trail_id = trail.id(); + let trail_id = self.id(); - let record = linked_table::remove(&mut trail.records, sequence_number); + let record = linked_table::remove(&mut self.records, sequence_number); record::destroy(record); event::emit(RecordDeleted { @@ -349,14 +354,14 @@ public fun delete_record( /// Requires `DeleteAllRecords` permission. This operation bypasses record locks. /// Returns the number of records deleted in this batch. public fun delete_records_batch( - trail: &mut AuditTrail, + self: &mut AuditTrail, cap: &Capability, limit: u64, clock: &Clock, ctx: &mut TxContext, ): u64 { - assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); - trail + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + self .roles .assert_capability_valid( cap, @@ -368,10 +373,10 @@ public fun delete_records_batch( let mut deleted = 0; let caller = ctx.sender(); let timestamp = clock.timestamp_ms(); - let trail_id = trail.id(); + let trail_id = self.id(); - while (deleted < limit && !trail.records.is_empty()) { - let (sequence_number, record) = trail.records.pop_front(); + while (deleted < limit && !self.records.is_empty()) { + let (sequence_number, record) = self.records.pop_front(); record.destroy(); @@ -392,13 +397,13 @@ public fun delete_records_batch( /// /// Requires `DeleteAuditTrail` permission and aborts if records still exist. public fun delete_audit_trail( - trail: AuditTrail, + self: AuditTrail, cap: &Capability, clock: &Clock, ctx: &mut TxContext, ) { - assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); - trail + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + self .roles .assert_capability_valid( cap, @@ -406,10 +411,10 @@ public fun delete_audit_trail( clock, ctx, ); - assert!(!locking::is_delete_trail_locked(&trail.locking_config, clock), ETrailDeleteLocked); - assert!(linked_table::is_empty(&trail.records), ETrailNotEmpty); + assert!(!locking::is_delete_trail_locked(&self.locking_config, clock), ETrailDeleteLocked); + assert!(linked_table::is_empty(&self.records), ETrailNotEmpty); - let trail_id = trail.id(); + let trail_id = self.id(); let timestamp = clock::timestamp_ms(clock); let AuditTrail { @@ -419,11 +424,13 @@ public fun delete_audit_trail( sequence_number: _, records, locking_config: _, - roles: _, + roles, immutable_metadata: _, updatable_metadata: _, version: _, - } = trail; + } = self; + + roles.destroy(); linked_table::destroy_empty(records); object::delete(id); @@ -436,34 +443,34 @@ public fun delete_audit_trail( /// Check if a record is locked based on the trail's locking configuration. /// Aborts with ERecordNotFound if the record doesn't exist. public fun is_record_locked( - trail: &AuditTrail, + self: &AuditTrail, sequence_number: u64, clock: &Clock, ): bool { - assert!(linked_table::contains(&trail.records, sequence_number), ERecordNotFound); + assert!(linked_table::contains(&self.records, sequence_number), ERecordNotFound); - let record = linked_table::borrow(&trail.records, sequence_number); + let record = linked_table::borrow(&self.records, sequence_number); let current_time = clock::timestamp_ms(clock); locking::is_delete_record_locked( - &trail.locking_config, + &self.locking_config, sequence_number, record::added_at(record), - trail.sequence_number, + self.sequence_number, current_time, ) } /// Update the locking configuration. Requires `UpdateLockingConfig` permission. public fun update_locking_config( - trail: &mut AuditTrail, + self: &mut AuditTrail, cap: &Capability, new_config: LockingConfig, clock: &Clock, ctx: &mut TxContext, ) { - assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); - trail + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + self .roles .assert_capability_valid( cap, @@ -471,19 +478,19 @@ public fun update_locking_config( clock, ctx, ); - set_config(&mut trail.locking_config, new_config); + set_config(&mut self.locking_config, new_config); } /// Update the `delete_record_lock` locking configuration public fun update_delete_record_window( - trail: &mut AuditTrail, + self: &mut AuditTrail, cap: &Capability, new_delete_record_lock: LockingWindow, clock: &Clock, ctx: &mut TxContext, ) { - assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); - trail + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + self .roles .assert_capability_valid( cap, @@ -491,19 +498,19 @@ public fun update_delete_record_window( clock, ctx, ); - set_delete_record_window(&mut trail.locking_config, new_delete_record_lock); + set_delete_record_window(&mut self.locking_config, new_delete_record_lock); } /// Update the `delete_trail_lock` locking configuration. public fun update_delete_trail_lock( - trail: &mut AuditTrail, + self: &mut AuditTrail, cap: &Capability, new_delete_trail_lock: TimeLock, clock: &Clock, ctx: &mut TxContext, ) { - assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); - trail + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + self .roles .assert_capability_valid( cap, @@ -511,19 +518,19 @@ public fun update_delete_trail_lock( clock, ctx, ); - set_delete_trail_lock(&mut trail.locking_config, new_delete_trail_lock); + set_delete_trail_lock(&mut self.locking_config, new_delete_trail_lock); } /// Update the `write_lock` locking configuration. public fun update_write_lock( - trail: &mut AuditTrail, + self: &mut AuditTrail, cap: &Capability, new_write_lock: TimeLock, clock: &Clock, ctx: &mut TxContext, ) { - assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); - trail + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + self .roles .assert_capability_valid( cap, @@ -531,19 +538,19 @@ public fun update_write_lock( clock, ctx, ); - set_write_lock(&mut trail.locking_config, new_write_lock); + set_write_lock(&mut self.locking_config, new_write_lock); } /// Update the trail's mutable metadata public fun update_metadata( - trail: &mut AuditTrail, + self: &mut AuditTrail, cap: &Capability, new_metadata: Option, clock: &Clock, ctx: &mut TxContext, ) { - assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); - trail + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + self .roles .assert_capability_valid( cap, @@ -551,23 +558,23 @@ public fun update_metadata( clock, ctx, ); - trail.updatable_metadata = new_metadata; + self.updatable_metadata = new_metadata; } // ===== Role and Capability Administration ===== /// Creates a new role with the provided permissions. public fun create_role( - trail: &mut AuditTrail, + self: &mut AuditTrail, cap: &Capability, role: String, permissions: VecSet, clock: &Clock, ctx: &mut TxContext, ) { - assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); role_map::create_role( - trail.access_mut(), + self.access_mut(), cap, role, permissions, @@ -579,16 +586,16 @@ public fun create_role( /// Updates permissions for an existing role. public fun update_role_permissions( - trail: &mut AuditTrail, + self: &mut AuditTrail, cap: &Capability, role: String, new_permissions: VecSet, clock: &Clock, ctx: &mut TxContext, ) { - assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); role_map::update_role( - trail.access_mut(), + self.access_mut(), cap, &role, new_permissions, @@ -600,21 +607,21 @@ public fun update_role_permissions( /// Deletes an existing role. public fun delete_role( - trail: &mut AuditTrail, + self: &mut AuditTrail, cap: &Capability, role: String, clock: &Clock, ctx: &mut TxContext, ) { - assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); - role_map::delete_role(trail.access_mut(), cap, &role, clock, ctx); + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + role_map::delete_role(self.access_mut(), cap, &role, clock, ctx); } /// Issues a new capability for an existing role. /// /// The capability object is transferred to `issued_to` if provided, otherwise to the caller. public fun new_capability( - trail: &mut AuditTrail, + self: &mut AuditTrail, cap: &Capability, role: String, issued_to: Option
, @@ -623,7 +630,7 @@ public fun new_capability( clock: &Clock, ctx: &mut TxContext, ) { - assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); let recipient = if (issued_to.is_some()) { let address_ref = issued_to.borrow(); @@ -633,7 +640,7 @@ public fun new_capability( }; let new_cap = role_map::new_capability( - trail.access_mut(), + self.access_mut(), cap, &role, issued_to, @@ -647,28 +654,36 @@ public fun new_capability( /// Revokes an issued capability by ID. public fun revoke_capability( - trail: &mut AuditTrail, + self: &mut AuditTrail, cap: &Capability, - capability_id: ID, + cap_to_revoke: ID, + cap_to_revoke_valid_until: Option, clock: &Clock, - ctx: &mut TxContext, + ctx: &TxContext, ) { - assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); - role_map::revoke_capability(trail.access_mut(), cap, capability_id, clock, ctx); + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + role_map::revoke_capability( + self.access_mut(), + cap, + cap_to_revoke, + cap_to_revoke_valid_until, + clock, + ctx + ); } /// Destroys a capability object. /// /// Requires a capability with `RevokeCapabilities` permission. public fun destroy_capability( - trail: &mut AuditTrail, + self: &mut AuditTrail, cap: &Capability, cap_to_destroy: Capability, clock: &Clock, ctx: &mut TxContext, ) { - assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); - trail + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + self .roles .assert_capability_valid( cap, @@ -676,7 +691,7 @@ public fun destroy_capability( clock, ctx, ); - role_map::destroy_capability(trail.access_mut(), cap_to_destroy); + role_map::destroy_capability(self.access_mut(), cap_to_destroy); } /// Destroys an initial admin capability. @@ -687,11 +702,11 @@ public fun destroy_capability( /// WARNING: If all initial admin capabilities are destroyed, the trail will be permanently /// sealed with no admin access possible. public fun destroy_initial_admin_capability( - trail: &mut AuditTrail, + self: &mut AuditTrail, cap_to_destroy: Capability, ) { - assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); - role_map::destroy_initial_admin_capability(trail.access_mut(), cap_to_destroy); + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + role_map::destroy_initial_admin_capability(self.access_mut(), cap_to_destroy); } /// Revokes an initial admin capability by ID. @@ -701,111 +716,151 @@ public fun destroy_initial_admin_capability( /// WARNING: If all initial admin capabilities are revoked, the trail will be permanently /// sealed with no admin access possible. public fun revoke_initial_admin_capability( - trail: &mut AuditTrail, + self: &mut AuditTrail, cap: &Capability, - capability_id: ID, + cap_to_revoke: ID, + cap_to_revoke_valid_until: Option, clock: &Clock, ctx: &mut TxContext, ) { - assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); - role_map::revoke_initial_admin_capability(trail.access_mut(), cap, capability_id, clock, ctx); + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + role_map::revoke_initial_admin_capability( + self.access_mut(), + cap, + cap_to_revoke, + cap_to_revoke_valid_until, + clock, + ctx); +} + +/// Remove expired entries from the `revoked_capabilities` denylist. +/// +/// Iterates through the revoked capabilities list and removes every entry whose +/// `valid_until` timestamp is **non-zero** and **less than** the current clock time, +/// because those capabilities are already naturally expired and no longer need to +/// occupy space in the denylist. +/// +/// Entries with `valid_until == 0` (i.e. capabilities that had no expiry) are kept, +/// since they remain potentially valid and must stay on the denylist. +/// +/// Parameters +/// ---------- +/// - cap: Reference to the capability used to authorize this operation. +/// Needs to grant the `CapabilityAdminPermissions::revoke` permission. +/// - clock: Reference to a Clock instance for obtaining the current timestamp. +/// - ctx: Reference to the transaction context. +/// +/// Errors: +/// - Aborts with any error documented by `assert_capability_valid` if the provided capability fails authorization checks. +public fun cleanup_revoked_capabilities( + self: &mut AuditTrail, + cap: &Capability, + clock: &Clock, + ctx: &TxContext, +) { + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + self.access_mut().cleanup_revoked_capabilities( + cap, + clock, + ctx, + ); } // ===== Trail Query Functions ===== /// Get the total number of records currently in the trail -public fun record_count(trail: &AuditTrail): u64 { - linked_table::length(&trail.records) +public fun record_count(self: &AuditTrail): u64 { + linked_table::length(&self.records) } /// Get the next sequence number (monotonic counter, never decrements) -public fun sequence_number(trail: &AuditTrail): u64 { - trail.sequence_number +public fun sequence_number(self: &AuditTrail): u64 { + self.sequence_number } /// Get the trail creator address -public fun creator(trail: &AuditTrail): address { - trail.creator +public fun creator(self: &AuditTrail): address { + self.creator } /// Get the trail creation timestamp -public fun created_at(trail: &AuditTrail): u64 { - trail.created_at +public fun created_at(self: &AuditTrail): u64 { + self.created_at } /// Get the trail's object ID -public fun id(trail: &AuditTrail): ID { - object::uid_to_inner(&trail.id) +public fun id(self: &AuditTrail): ID { + object::uid_to_inner(&self.id) } /// Get the trail name -public fun name(trail: &AuditTrail): Option { - trail.immutable_metadata.map!(|metadata| metadata.name) +public fun name(self: &AuditTrail): Option { + self.immutable_metadata.map!(|metadata| metadata.name) } /// Get the trail description -public fun description(trail: &AuditTrail): Option { - if (trail.immutable_metadata.is_some()) { - option::borrow(&trail.immutable_metadata).description +public fun description(self: &AuditTrail): Option { + if (self.immutable_metadata.is_some()) { + option::borrow(&self.immutable_metadata).description } else { option::none() } } /// Get the updatable metadata -public fun metadata(trail: &AuditTrail): &Option { - &trail.updatable_metadata +public fun metadata(self: &AuditTrail): &Option { + &self.updatable_metadata } /// Get the locking configuration -public fun locking_config(trail: &AuditTrail): &LockingConfig { - &trail.locking_config +public fun locking_config(self: &AuditTrail): &LockingConfig { + &self.locking_config } /// Check if the trail is empty -public fun is_empty(trail: &AuditTrail): bool { - linked_table::is_empty(&trail.records) +public fun is_empty(self: &AuditTrail): bool { + linked_table::is_empty(&self.records) } /// Get the first sequence number -public fun first_sequence(trail: &AuditTrail): Option { - *linked_table::front(&trail.records) +public fun first_sequence(self: &AuditTrail): Option { + *linked_table::front(&self.records) } /// Get the last sequence number -public fun last_sequence(trail: &AuditTrail): Option { - *linked_table::back(&trail.records) +public fun last_sequence(self: &AuditTrail): Option { + *linked_table::back(&self.records) } // ===== Record Query Functions ===== /// Get a record by sequence number -public fun get_record(trail: &AuditTrail, sequence_number: u64): &Record { - assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); - assert!(linked_table::contains(&trail.records, sequence_number), ERecordNotFound); - linked_table::borrow(&trail.records, sequence_number) +public fun get_record(self: &AuditTrail, sequence_number: u64): &Record { + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + assert!(linked_table::contains(&self.records, sequence_number), ERecordNotFound); + linked_table::borrow(&self.records, sequence_number) } /// Check if a record exists at the given sequence number -public fun has_record(trail: &AuditTrail, sequence_number: u64): bool { - assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); - linked_table::contains(&trail.records, sequence_number) +public fun has_record(self: &AuditTrail, sequence_number: u64): bool { + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + linked_table::contains(&self.records, sequence_number) } /// Returns all records of the audit trail -public fun records(trail: &AuditTrail): &LinkedTable> { - assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); - &trail.records +public fun records(self: &AuditTrail): &LinkedTable> { + assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + &self.records } // ===== Access Control Functions ===== -/// Returns the RoleMap managing access for the audit trail. +/// Returns a reference to the RoleMap managing access (roles and capabilities) for the audit trail. public fun access(trail: &AuditTrail): &RoleMap { assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); &trail.roles } -/// Returns a mutable reference to the RoleMap managing access for the audit trail. +/// Returns a mutable reference to the RoleMap managing access (roles and capabilities) for the audit trail. public fun access_mut(trail: &mut AuditTrail): &mut RoleMap { assert!(trail.version == PACKAGE_VERSION, EPackageVersionMismatch); &mut trail.roles diff --git a/audit-trail-move/sources/record.move b/audit-trail-move/sources/record.move index 69ead0fd..987490e6 100644 --- a/audit-trail-move/sources/record.move +++ b/audit-trail-move/sources/record.move @@ -50,37 +50,37 @@ public(package) fun new( // ===== Getters ===== /// Get the stored data from a record -public fun data(record: &Record): &D { - &record.data +public fun data(self: &Record): &D { + &self.data } /// Get the record metadata -public fun metadata(record: &Record): &Option { - &record.metadata +public fun metadata(self: &Record): &Option { + &self.metadata } /// Get the record sequence number -public fun sequence_number(record: &Record): u64 { - record.sequence_number +public fun sequence_number(self: &Record): u64 { + self.sequence_number } /// Get who added the record -public fun added_by(record: &Record): address { - record.added_by +public fun added_by(self: &Record): address { + self.added_by } /// Get when the record was added (milliseconds) -public fun added_at(record: &Record): u64 { - record.added_at +public fun added_at(self: &Record): u64 { + self.added_at } /// Get the correction tracker for this record -public fun correction(record: &Record): &RecordCorrection { - &record.correction +public fun correction(self: &Record): &RecordCorrection { + &self.correction } /// Destroy a record -public(package) fun destroy(record: Record) { +public(package) fun destroy(self: Record) { let Record { data: _, metadata: _, @@ -88,7 +88,7 @@ public(package) fun destroy(record: Record) { added_by: _, added_at: _, correction: _, - } = record; + } = self; } /// Bidirectional correction tracking for audit records diff --git a/audit-trail-move/tests/capability_tests.move b/audit-trail-move/tests/capability_tests.move index 43640af9..72440b15 100644 --- a/audit-trail-move/tests/capability_tests.move +++ b/audit-trail-move/tests/capability_tests.move @@ -150,15 +150,11 @@ fun test_new_capability() { cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); }; - // Issue first capability and verify it's tracked + // Issue first capability ts::next_tx(&mut scenario, admin_user); let cap1_id = { let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); - // Verify initial state - only admin capability should be tracked - let initial_cap_count = trail.access().issued_capabilities().size(); - assert!(initial_cap_count == 1, 0); // Only admin cap - let cap1 = test_utils::new_capability_without_restrictions( trail.access_mut(), &admin_cap, @@ -171,23 +167,17 @@ fun test_new_capability() { let cap1_id = object::id(&cap1); - // Verify capability ID is tracked in issued_capabilities - assert!(trail.access().issued_capabilities().size() == initial_cap_count + 1, 3); - assert!(trail.access().issued_capabilities().contains(&cap1_id), 4); - transfer::public_transfer(cap1, user1); cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); cap1_id }; - // Issue second capability and verify both are tracked with unique IDs + // Issue second capability and verify both have unique IDs ts::next_tx(&mut scenario, admin_user); let _cap2_id = { let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); - let previous_cap_count = trail.access().issued_capabilities().size(); - let cap2 = test_utils::new_capability_without_restrictions( trail.access_mut(), &admin_cap, @@ -198,13 +188,8 @@ fun test_new_capability() { let cap2_id = object::id(&cap2); - // Verify both capabilities are tracked - assert!(trail.access().issued_capabilities().size() == previous_cap_count + 1, 5); - assert!(trail.access().issued_capabilities().contains(&cap1_id), 6); - assert!(trail.access().issued_capabilities().contains(&cap2_id), 7); - // Verify capabilities have unique IDs - assert!(cap1_id != cap2_id, 8); + assert!(cap1_id != cap2_id, 3); transfer::public_transfer(cap2, user2); cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); @@ -255,16 +240,15 @@ fun test_revoke_capability() { (cap1_id, cap2_id) }; - // Test: Revoke first capability and verify it's removed from tracking + // Test: Revoke first capability and verify it's tracked in the deny list ts::next_tx(&mut scenario, admin_user); { let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); let cap1 = ts::take_from_address(&scenario, user1); - // Verify both capabilities are tracked before revocation - let cap_count_before = trail.access().issued_capabilities().size(); - assert!(trail.access().issued_capabilities().contains(&cap1_id), 0); - assert!(trail.access().issued_capabilities().contains(&cap2_id), 1); + // Verify the deny list is empty before revocation + let cap_count_before = trail.access().revoked_capabilities().length(); + assert!(cap_count_before == 0, 0); // Revoke the capability trail @@ -272,15 +256,14 @@ fun test_revoke_capability() { .revoke_capability( &admin_cap, cap1.id(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); - // Verify capability was removed from tracking - assert!(trail.access().issued_capabilities().size() == cap_count_before - 1, 2); - assert!(!trail.access().issued_capabilities().contains(&cap1_id), 3); - // Verify other capability is still tracked - assert!(trail.access().issued_capabilities().contains(&cap2_id), 4); + // Verify capability has been added to the deny list + assert!(trail.access().revoked_capabilities().length() == cap_count_before + 1, 1); + assert!(trail.access().revoked_capabilities().contains(cap1_id), 2); ts::return_to_address(user1, cap1); cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); @@ -289,7 +272,7 @@ fun test_revoke_capability() { // Verify revoked capability object still exists (just invalidated) ts::next_tx(&mut scenario, user1); { - assert!(ts::has_most_recent_for_sender(&scenario), 5); + assert!(ts::has_most_recent_for_sender(&scenario), 3); }; // Test: Revoke second capability @@ -298,20 +281,22 @@ fun test_revoke_capability() { let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); let cap2 = ts::take_from_address(&scenario, user2); - let cap_count_before = trail.access().issued_capabilities().size(); + let cap_count_before = trail.access().revoked_capabilities().length(); + assert!(cap_count_before == 1, 4); // only the first revoked capability (cap1) should be in the list trail .access_mut() .revoke_capability( &admin_cap, cap2.id(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); - // Verify capability was removed from tracking - assert!(trail.access().issued_capabilities().size() == cap_count_before - 1, 6); - assert!(!trail.access().issued_capabilities().contains(&cap2_id), 7); + // Verify capability has been added to the deny list + assert!(trail.access().revoked_capabilities().length() == cap_count_before + 1, 5); + assert!(trail.access().revoked_capabilities().contains(cap2_id), 6); ts::return_to_address(user2, cap2); cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); @@ -332,7 +317,7 @@ fun test_destroy_capability() { // Issue two capabilities ts::next_tx(&mut scenario, admin_user); - let (cap1_id, cap2_id) = { + let (_cap1_id, _cap2_id) = { let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); let cap1 = test_utils::new_capability_without_restrictions( @@ -366,28 +351,16 @@ fun test_destroy_capability() { let mut trail = ts::take_shared>(&scenario); let cap1 = ts::take_from_sender(&scenario); - // Verify both capabilities are tracked before destruction - let cap_count_before = trail.access().issued_capabilities().size(); - assert!(trail.access().issued_capabilities().contains(&cap1_id), 0); - assert!(trail.access().issued_capabilities().contains(&cap2_id), 1); - // Destroy the capability trail.access_mut().destroy_capability(cap1); - // Verify capability was removed from tracking - assert!(trail.access().issued_capabilities().size() == cap_count_before - 1, 2); - assert!(!trail.access().issued_capabilities().contains(&cap1_id), 3); - - // Verify other capability is still tracked - assert!(trail.access().issued_capabilities().contains(&cap2_id), 4); - ts::return_shared(trail); }; // Verify destroyed capability no longer exists ts::next_tx(&mut scenario, user1); { - assert!(!ts::has_most_recent_for_sender(&scenario), 5); + assert!(!ts::has_most_recent_for_sender(&scenario), 0); }; // Test: User2 destroys their own capability @@ -396,26 +369,15 @@ fun test_destroy_capability() { let mut trail = ts::take_shared>(&scenario); let cap2 = ts::take_from_sender(&scenario); - let cap_count_before = trail.access().issued_capabilities().size(); - trail.access_mut().destroy_capability(cap2); - // Verify capability was removed from tracking - assert!(trail.access().issued_capabilities().size() == cap_count_before - 1, 6); - assert!(!trail.access().issued_capabilities().contains(&cap2_id), 7); - ts::return_shared(trail); }; - // Verify only admin capability remains - ts::next_tx(&mut scenario, admin_user); + // Verify destroyed capability no longer exists + ts::next_tx(&mut scenario, user2); { - let trail = ts::take_shared>(&scenario); - - // Only the initial admin capability should remain - assert!(trail.access().issued_capabilities().size() == 1, 8); - - ts::return_shared(trail); + assert!(!ts::has_most_recent_for_sender(&scenario), 1); }; ts::end(scenario); @@ -427,7 +389,7 @@ fun test_destroy_capability() { /// - Multiple capabilities can be created for different roles /// - Capabilities can be used to perform authorized actions /// - Capabilities can be revoked or destroyed -/// - issued_capabilities tracking remains accurate throughout the lifecycle +/// - revoked_capabilities tracking remains accurate throughout the lifecycle #[test] fun test_capability_lifecycle() { let admin_user = @0xAD; @@ -444,9 +406,6 @@ fun test_capability_lifecycle() { { let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); - // Initially only admin cap should be tracked - assert!(trail.access().issued_capabilities().size() == 1, 0); - trail .access_mut() .create_role( @@ -463,7 +422,7 @@ fun test_capability_lifecycle() { // Issue capabilities ts::next_tx(&mut scenario, admin_user); - let (record_cap_id, role_cap_id) = { + let (_record_cap_id, role_cap_id) = { let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); let record_cap = test_utils::new_capability_without_restrictions( @@ -486,11 +445,6 @@ fun test_capability_lifecycle() { let role_cap_id = object::id(&role_cap); transfer::public_transfer(role_cap, role_admin_user); - // Verify all capabilities are tracked - assert!(trail.access().issued_capabilities().size() == 3, 1); // admin + record + role - assert!(trail.access().issued_capabilities().contains(&record_cap_id), 2); - assert!(trail.access().issued_capabilities().contains(&role_cap_id), 3); - cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); (record_cap_id, role_cap_id) @@ -523,10 +477,6 @@ fun test_capability_lifecycle() { trail.access_mut().destroy_capability(record_cap); - // Verify capability was removed - assert!(trail.access().issued_capabilities().size() == 2, 4); // admin + role - assert!(!trail.access().issued_capabilities().contains(&record_cap_id), 5); - ts::return_shared(trail); }; @@ -536,18 +486,22 @@ fun test_capability_lifecycle() { let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); let role_cap = ts::take_from_address(&scenario, role_admin_user); + // Initially the deny list should be empty + assert!(trail.access().revoked_capabilities().length() == 0, 0); + trail .access_mut() .revoke_capability( &admin_cap, role_cap.id(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); - // Verify capability was removed - assert!(trail.access().issued_capabilities().size() == 1, 6); // only admin remains - assert!(!trail.access().issued_capabilities().contains(&role_cap_id), 7); + // Verify role_cap is in the deny list now + assert!(trail.access().revoked_capabilities().length() == 1, 1); + assert!(trail.access().revoked_capabilities().contains(role_cap_id), 2); ts::return_to_address(role_admin_user, role_cap); @@ -694,7 +648,13 @@ fun test_revoked_capability_cannot_be_used() { trail .access_mut() - .revoke_capability(&admin_cap, user_cap.id(), &clock, ts::ctx(&mut scenario)); + .revoke_capability( + &admin_cap, + user_cap.id(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); ts::return_to_address(user, user_cap); cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); @@ -843,7 +803,13 @@ fun test_revoke_capability_permission_denied() { trail .access_mut() - .revoke_capability(&user1_cap, user2_cap.id(), &clock, ts::ctx(&mut scenario)); + .revoke_capability( + &user1_cap, + user2_cap.id(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); ts::return_to_address(user2, user2_cap); ts::return_to_sender(&scenario, user1_cap); diff --git a/audit-trail-move/tests/metadata_tests.move b/audit-trail-move/tests/metadata_tests.move index f690efb4..10070682 100644 --- a/audit-trail-move/tests/metadata_tests.move +++ b/audit-trail-move/tests/metadata_tests.move @@ -14,6 +14,7 @@ use audit_trail::{ }; use iota::test_scenario as ts; use std::string; +use std::option::none; use tf_components::{capability::Capability, timelock}; // ===== Success Case Tests ===== @@ -274,7 +275,7 @@ fun test_update_metadata_revoked_capability() { trail .access_mut() - .revoke_capability(&admin_cap, metadata_cap.id(), &clock, ts::ctx(&mut scenario)); + .revoke_capability(&admin_cap, metadata_cap.id(), none(), &clock, ts::ctx(&mut scenario)); ts::return_to_address(metadata_admin_user, metadata_cap); cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); From fafd749a1c9f0e68c493c976f50e435bd00ca343 Mon Sep 17 00:00:00 2001 From: Christof Gerritsma Date: Thu, 19 Mar 2026 17:46:55 +0100 Subject: [PATCH 088/189] Several renamings in `record_tags.move` (#223) Several renamings and slightly changes in the record_tag related interfaces. Co-authored-by: Yasir --- audit-trail-move/sources/record_tags.move | 104 +++++++++++++--------- 1 file changed, 63 insertions(+), 41 deletions(-) diff --git a/audit-trail-move/sources/record_tags.move b/audit-trail-move/sources/record_tags.move index 17f7f907..b538bdfe 100644 --- a/audit-trail-move/sources/record_tags.move +++ b/audit-trail-move/sources/record_tags.move @@ -9,25 +9,43 @@ use iota::{vec_map::{Self, VecMap}, vec_set::{Self, VecSet}}; use std::string::String; use tf_components::{capability::Capability, role_map::{Self, RoleMap}}; +// ----------- RoleTags ------- + /// Stores all record tag related data associated with a role in the RoleMap. -public struct RecordTags has copy, drop, store { +/// Contains a list of allowlisted tags for the role. +public struct RoleTags has copy, drop, store { tags: VecSet, } -/// Create a role-scoped record-tag access list. -public fun new_record_tags(tags: vector): RecordTags { - RecordTags { +/// Create a new `RoleTags`. +public fun new_role_tag_list(tags: vector): RoleTags { + RoleTags { tags: vec_set::from_keys(tags), } } -/// Get the allowlisted record tags for a role. -public fun allowed_record_tags(record_tags: &RecordTags): &VecSet { - &record_tags.tags +/// Get the allowlisted record tags for a role from a `RoleTags`. +public fun tags(self: &RoleTags): &VecSet { + &self.tags +} + +// ----------- TagRegistry ------- + +/// A registry of tags available for use on an audit trail, along with usage counts +/// to track how many records and roles are currently using each tag. +/// Usage counts for roles and tags are summed and build a combined usage count. +public struct TagRegistry has copy, drop, store { + tag_map: VecMap, } -/// Create a zeroed usage counter for all tags in the trail list -public(package) fun new_usage(mut tags: vector): VecMap { +/// Get a mapping of record tag names to `u64`. +public fun tag_map(self: &TagRegistry): &VecMap { + &self.tag_map +} + +/// Create a `TagRegistry` with zeroed usage counts to manage a list of available tags to be +/// associated with records and roles on an audit trail. +public(package) fun new_tag_registry(mut tags: vector): TagRegistry { let mut usage = vec_map::empty(); tags.reverse(); @@ -35,25 +53,25 @@ public(package) fun new_usage(mut tags: vector): VecMap { vec_map::insert(&mut usage, tags.pop_back(), 0); }; - usage + TagRegistry { tag_map: usage } } -/// Returns true when all provided role tags are defined on the trail. -public(package) fun defined_for_trail( - available_tags: &VecMap, - record_tags: &Option, +/// Returns true when all provided `role_tags` (tags associated with a role) are contained in the `TagRegistry`. +public(package) fun contains_all_role_tags( + self: &TagRegistry, + role_tags: &Option, ): bool { - if (!record_tags.is_some()) { + if (!role_tags.is_some()) { return true }; - let tags = &option::borrow(record_tags).tags; + let tags = &option::borrow(role_tags).tags; let allowed_tag_keys = iota::vec_set::keys(tags); let mut i = 0; let tag_count = allowed_tag_keys.length(); while (i < tag_count) { - if (!iota::vec_map::contains(available_tags, &allowed_tag_keys[i])) { + if (!iota::vec_map::contains(&self.tag_map, &allowed_tag_keys[i])) { return false }; i = i + 1; @@ -62,14 +80,37 @@ public(package) fun defined_for_trail( true } -/// Returns true when the requested tag exists in the trail registry. -public(package) fun is_defined(available_tags: &VecMap, tag: &String): bool { - iota::vec_map::contains(available_tags, tag) +/// Returns true when the specified tag is contained in the `TagRegistry`. +public(package) fun contains(self: &TagRegistry, tag: &String): bool { + iota::vec_map::contains(&self.tag_map, tag) +} + +/// Returns the current combined usage count (sum of role and record usages) for a tag. +/// Returns `Option::none()` if the tag is not contained in the registry. +public(package) fun usage_count(self: &TagRegistry, tag: &String): Option { + if (self.tag_map.contains(tag)) { + option::some(*self.tag_map.get(tag)) + } else { + option::none() + } +} + +public(package) fun increment_usage_count(self: &mut TagRegistry, tag: &String) { + let counters = vec_map::get_mut(&mut self.tag_map, tag); + *counters = *counters + 1; } +public(package) fun decrement_usage_count(self: &mut TagRegistry, tag: &String) { + let counters = vec_map::get_mut(&mut self.tag_map, tag); + *counters = *counters - 1; +} + + +// ----------- RoleMap related ------- + /// Returns true when the capability's role data allows the requested tag. public(package) fun role_allows( - roles: &RoleMap, + roles: &RoleMap, cap: &Capability, tag: &String, ): bool { @@ -80,23 +121,4 @@ public(package) fun role_allows( let tags = &option::borrow(role_tags).tags; iota::vec_set::contains(tags, tag) -} - -/// Returns the current combined usage count for a tag across records and roles. -public(package) fun usage_count(usage: &VecMap, tag: &String): u64 { - if (vec_map::contains(usage, tag)) { - *vec_map::get(usage, tag) - } else { - 0 - } -} - -public(package) fun increment_tag_usage(usage: &mut VecMap, tag: &String) { - let count = vec_map::get_mut(usage, tag); - *count = *count + 1; -} - -public(package) fun decrement_tag_usage(usage: &mut VecMap, tag: &String) { - let count = vec_map::get_mut(usage, tag); - *count = *count - 1; -} +} \ No newline at end of file From 0437f1a5ac52c3a5b2ceab54da386f206e5ffadf Mon Sep 17 00:00:00 2001 From: chrisgitiota Date: Thu, 19 Mar 2026 18:41:19 +0100 Subject: [PATCH 089/189] First part of needed changes to use the latest record_tags.move version --- audit-trail-move/sources/audit_trail.move | 46 +++++++++++------------ audit-trail-move/sources/record_tags.move | 40 +++++++++++++++++--- audit-trail-move/tests/record_tests.move | 6 +-- audit-trail-move/tests/role_tests.move | 6 +-- 4 files changed, 63 insertions(+), 35 deletions(-) diff --git a/audit-trail-move/sources/audit_trail.move b/audit-trail-move/sources/audit_trail.move index 52519824..8921728b 100644 --- a/audit-trail-move/sources/audit_trail.move +++ b/audit-trail-move/sources/audit_trail.move @@ -19,7 +19,7 @@ use audit_trail::{ }, permission::{Self, Permission}, record::{Self, Record}, - record_tags::{Self, RecordTags} + record_tags::{Self, RoleTags} }; use iota::{ clock::{Self, Clock}, @@ -87,11 +87,11 @@ public struct AuditTrail has key, store { /// LinkedTable mapping sequence numbers to records records: LinkedTable>, /// Canonical list of tags that may be attached to records in this trail with their combined usage counts - tags: VecMap, + tags: record_tags::TagRegistry, /// Deletion locking rules locking_config: LockingConfig, /// A list of role definitions consisting of a unique role specifier and a list of associated permissions - roles: RoleMap, + roles: RoleMap, /// Set at creation, cannot be changed immutable_metadata: Option, /// Can be updated by holders of MetadataUpdate permission @@ -216,7 +216,7 @@ public fun create( ctx, ); - let tags = record_tags::new_usage(tags); + let tags = record_tags::new_tag_registry(tags); let trail = AuditTrail { id: trail_uid, @@ -276,7 +276,7 @@ fun assert_record_tag_allowed( }; let requested_tag = option::borrow(tag); - assert!(record_tags::is_defined(&self.tags, requested_tag), ERecordTagNotDefined); + assert!(record_tags::contains(&self.tags, requested_tag), ERecordTagNotDefined); assert!(record_tags::role_allows(&self.roles, cap, requested_tag), ERecordTagNotAllowed); } @@ -312,7 +312,7 @@ public fun add_record( let seq = self.sequence_number; if (record_tag.is_some()) { - record_tags::increment_tag_usage(&mut self.tags, option::borrow(&record_tag)); + record_tags::increment_usage_count(&mut self.tags, option::borrow(&record_tag)); }; let record = record::new( @@ -365,7 +365,7 @@ public fun delete_record( let record = linked_table::remove(&mut self.records, sequence_number); if (record::tag(&record).is_some()) { - record_tags::decrement_tag_usage(&mut self.tags, option::borrow(record::tag(&record))); + record_tags::decrement_usage_count(&mut self.tags, option::borrow(record::tag(&record))); }; record::destroy(record); @@ -407,7 +407,7 @@ public fun delete_records_batch( let (sequence_number, record) = self.records.pop_front(); if (record::tag(&record).is_some()) { - record_tags::decrement_tag_usage(&mut self.tags, option::borrow(record::tag(&record))); + record_tags::decrement_usage_count(&mut self.tags, option::borrow(record::tag(&record))); }; record.destroy(); @@ -464,12 +464,9 @@ public fun delete_audit_trail( } = self; roles.destroy(); - linked_table::destroy_empty(records); - while (!vec_map::is_empty(&tags)) { - let (_, _) = vec_map::pop(&mut tags); - }; - vec_map::destroy_empty(tags); + tags.destroy(); + object::delete(id); event::emit(AuditTrailDeleted { trail_id, timestamp }); @@ -610,8 +607,8 @@ public fun add_record_tag( self.roles.assert_capability_valid(cap, &permission::add_record_tags(), clock, ctx); - assert!(!iota::vec_map::contains(&self.tags, &tag), ERecordTagAlreadyDefined); - vec_map::insert(&mut self.tags, tag, 0); + assert!(!self.tags.contains(&tag), ERecordTagAlreadyDefined); + self.tags.insert_tag(tag, 0); } /// Removes a record tag from the trail registry if it is not used by any record. @@ -626,9 +623,10 @@ public fun remove_record_tag( self.roles.assert_capability_valid(cap, &permission::delete_record_tags(), clock, ctx); - assert!(iota::vec_map::contains(&self.tags, &tag), ERecordTagNotDefined); - assert!(record_tags::usage_count(&self.tags, &tag) == 0, ERecordTagInUse); - vec_map::remove(&mut self.tags, &tag); + assert!(self.tags.contains(&tag), ERecordTagNotDefined); + assert!(!self.tags.is_in_use(&tag), ERecordTagInUse); + + self.tags.remove_tag(&tag); } // ===== Role and Capability Administration ===== @@ -639,13 +637,13 @@ public fun create_role( cap: &Capability, role: String, permissions: VecSet, - record_tags: Option, + record_tags: Option, clock: &Clock, ctx: &mut TxContext, ) { assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); - assert!(record_tags::defined_for_trail(&self.tags, &record_tags), ERecordTagNotDefined); + assert!(self.tags.contains_all_role_tags(&record_tags), ERecordTagNotDefined); role_map::create_role( self.access_mut(), @@ -675,13 +673,13 @@ public fun update_role_permissions( cap: &Capability, role: String, new_permissions: VecSet, - record_tags: Option, + record_tags: Option, clock: &Clock, ctx: &mut TxContext, ) { assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); - assert!(record_tags::defined_for_trail(&self.tags, &record_tags), ERecordTagNotDefined); + assert!(self.tags.contains_all_role_tags(&record_tags), ERecordTagNotDefined); let old_record_tags = *role_map::get_role_data(self.access(), &role); role_map::update_role( self.access_mut(), @@ -983,7 +981,7 @@ public fun records(self: &AuditTrail): &LinkedTable(self: &AuditTrail): &RoleMap { +public fun access(self: &AuditTrail): &RoleMap { assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); &self.roles } @@ -991,7 +989,7 @@ public fun access(self: &AuditTrail): &RoleMap( self: &mut AuditTrail, -): &mut RoleMap { +): &mut RoleMap { assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); &mut self.roles } diff --git a/audit-trail-move/sources/record_tags.move b/audit-trail-move/sources/record_tags.move index b538bdfe..2b4dec6e 100644 --- a/audit-trail-move/sources/record_tags.move +++ b/audit-trail-move/sources/record_tags.move @@ -18,7 +18,7 @@ public struct RoleTags has copy, drop, store { } /// Create a new `RoleTags`. -public fun new_role_tag_list(tags: vector): RoleTags { +public fun new_role_tags(tags: vector): RoleTags { RoleTags { tags: vec_set::from_keys(tags), } @@ -56,6 +56,22 @@ public(package) fun new_tag_registry(mut tags: vector): TagRegistry { TagRegistry { tag_map: usage } } +/// Destroys the `TagRegistry` by emptying the internal tag map and then destroying it. +public(package) fun destroy(mut self: TagRegistry) { + while (!self.tag_map.is_empty()) { + let (_, _) = self.tag_map.pop(); + }; + self.tag_map.destroy_empty(); +} + +public(package) fun insert_tag(self: &mut TagRegistry, tag: String, usage_count: u64) { + self.tag_map.insert(tag, usage_count); +} + +public(package) fun remove_tag(self: &mut TagRegistry, tag: &String) { + self.tag_map.remove(tag); +} + /// Returns true when all provided `role_tags` (tags associated with a role) are contained in the `TagRegistry`. public(package) fun contains_all_role_tags( self: &TagRegistry, @@ -95,14 +111,28 @@ public(package) fun usage_count(self: &TagRegistry, tag: &String): Option { } } +/// Increments the usage count for a tag by 1. +/// Will be without effect if the tag is not contained in the registry. public(package) fun increment_usage_count(self: &mut TagRegistry, tag: &String) { - let counters = vec_map::get_mut(&mut self.tag_map, tag); - *counters = *counters + 1; + if (self.tag_map.contains(tag)) { + let counters = vec_map::get_mut(&mut self.tag_map, tag); + *counters = *counters + 1; + }; } +/// Decrements the usage count for a tag by 1. +/// Will be without effect if the tag is not contained in the registry. public(package) fun decrement_usage_count(self: &mut TagRegistry, tag: &String) { - let counters = vec_map::get_mut(&mut self.tag_map, tag); - *counters = *counters - 1; + if (self.tag_map.contains(tag)) { + let counters = vec_map::get_mut(&mut self.tag_map, tag); + *counters = *counters - 1; + }; +} + +/// Returns if the specified is in use. +/// Returns false if the tag is not contained in the registry. +public(package) fun is_in_use(self: &TagRegistry, tag: &String): bool { + self.usage_count(tag).borrow_with_default(&0) == 0 } diff --git a/audit-trail-move/tests/record_tests.move b/audit-trail-move/tests/record_tests.move index 8d2913a2..74a359d1 100644 --- a/audit-trail-move/tests/record_tests.move +++ b/audit-trail-move/tests/record_tests.move @@ -136,7 +136,7 @@ fun test_add_tagged_record_with_matching_role_tags() { &admin_cap, string::utf8(b"TaggedWriter"), permission::record_admin_permissions(), - std::option::some(record_tags::new_record_tags(vector[string::utf8(b"finance")])), + std::option::some(record_tags::new_role_tags(vector[string::utf8(b"finance")])), &clock, ts::ctx(&mut scenario), ); @@ -275,7 +275,7 @@ fun test_add_tagged_record_requires_trail_defined_tag() { &admin_cap, string::utf8(b"TaggedWriter"), permission::record_admin_permissions(), - std::option::some(record_tags::new_record_tags(vector[string::utf8(b"finance")])), + std::option::some(record_tags::new_role_tags(vector[string::utf8(b"finance")])), &clock, ts::ctx(&mut scenario), ); @@ -343,7 +343,7 @@ fun test_remove_record_tag_rejects_in_use_tag() { &admin_cap, string::utf8(b"TaggedWriter"), permission::record_admin_permissions(), - std::option::some(record_tags::new_record_tags(vector[string::utf8(b"finance")])), + std::option::some(record_tags::new_role_tags(vector[string::utf8(b"finance")])), &clock, ts::ctx(&mut scenario), ); diff --git a/audit-trail-move/tests/role_tests.move b/audit-trail-move/tests/role_tests.move index 03202a35..2fa3bdcc 100644 --- a/audit-trail-move/tests/role_tests.move +++ b/audit-trail-move/tests/role_tests.move @@ -249,7 +249,7 @@ fun test_create_role_rejects_undefined_record_tags() { &admin_cap, string::utf8(b"TaggedRole"), perms, - std::option::some(record_tags::new_record_tags(vector[string::utf8(b"finance")])), + std::option::some(record_tags::new_role_tags(vector[string::utf8(b"finance")])), &clock, ts::ctx(&mut scenario), ); @@ -354,7 +354,7 @@ fun test_remove_record_tag_rejects_role_only_usage() { &admin_cap, string::utf8(b"TaggedRole"), perms, - std::option::some(record_tags::new_record_tags(vector[string::utf8(b"finance")])), + std::option::some(record_tags::new_role_tags(vector[string::utf8(b"finance")])), &clock, ts::ctx(&mut scenario), ); @@ -763,7 +763,7 @@ fun test_update_role_permissions_rejects_undefined_record_tags() { &admin_cap, string::utf8(b"TestRole"), permission::from_vec(vector[permission::add_record()]), - std::option::some(record_tags::new_record_tags(vector[string::utf8(b"finance")])), + std::option::some(record_tags::new_role_tags(vector[string::utf8(b"finance")])), &clock, ts::ctx(&mut scenario), ); From 7e9f4bf798605ec13e3efa80f9c1d07f9094cbb5 Mon Sep 17 00:00:00 2001 From: chrisgitiota Date: Fri, 20 Mar 2026 15:00:07 +0100 Subject: [PATCH 090/189] Move sources are compilable now - some of the tests are failing though --- audit-trail-move/sources/audit_trail.move | 20 +++++++++---------- audit-trail-move/sources/record_tags.move | 4 ++++ .../tests/create_audit_trail_tests.move | 10 +++++----- 3 files changed, 19 insertions(+), 15 deletions(-) diff --git a/audit-trail-move/sources/audit_trail.move b/audit-trail-move/sources/audit_trail.move index 8921728b..b7be786a 100644 --- a/audit-trail-move/sources/audit_trail.move +++ b/audit-trail-move/sources/audit_trail.move @@ -19,7 +19,7 @@ use audit_trail::{ }, permission::{Self, Permission}, record::{Self, Record}, - record_tags::{Self, RoleTags} + record_tags::{Self, RoleTags, TagRegistry} }; use iota::{ clock::{Self, Clock}, @@ -656,12 +656,12 @@ public fun create_role( ); if (record_tags.is_some()) { - let tags = iota::vec_set::keys(record_tags::allowed_record_tags(option::borrow(&record_tags))); + let tags = record_tags.borrow().tags().keys(); let mut i = 0; let tag_count = tags.length(); while (i < tag_count) { - record_tags::increment_tag_usage(&mut self.tags, &tags[i]); + self.tags.increment_usage_count(&tags[i]); i = i + 1; }; }; @@ -692,23 +692,23 @@ public fun update_role_permissions( ); if (old_record_tags.is_some()) { - let tags = iota::vec_set::keys(record_tags::allowed_record_tags(option::borrow(&old_record_tags))); + let tags = old_record_tags.borrow().tags().keys(); let mut i = 0; let tag_count = tags.length(); while (i < tag_count) { - record_tags::decrement_tag_usage(&mut self.tags, &tags[i]); + self.tags.decrement_usage_count(&tags[i]); i = i + 1; }; }; if (record_tags.is_some()) { - let tags = iota::vec_set::keys(record_tags::allowed_record_tags(option::borrow(&record_tags))); + let tags = record_tags.borrow().tags().keys(); let mut i = 0; let tag_count = tags.length(); while (i < tag_count) { - record_tags::increment_tag_usage(&mut self.tags, &tags[i]); + self.tags.increment_usage_count(&tags[i]); i = i + 1; }; }; @@ -727,12 +727,12 @@ public fun delete_role( role_map::delete_role(self.access_mut(), cap, &role, clock, ctx); if (old_record_tags.is_some()) { - let tags = iota::vec_set::keys(record_tags::allowed_record_tags(option::borrow(&old_record_tags))); + let tags = old_record_tags.borrow().tags().keys(); let mut i = 0; let tag_count = tags.length(); while (i < tag_count) { - record_tags::decrement_tag_usage(&mut self.tags, &tags[i]); + self.tags.decrement_usage_count(&tags[i]); i = i + 1; }; }; @@ -939,7 +939,7 @@ public fun locking_config(self: &AuditTrail): &LockingConfig } /// Get the trail-defined record tags and their combined usage counts. -public fun tags(self: &AuditTrail): &VecMap { +public fun tags(self: &AuditTrail): &TagRegistry { &self.tags } diff --git a/audit-trail-move/sources/record_tags.move b/audit-trail-move/sources/record_tags.move index 2b4dec6e..9025f229 100644 --- a/audit-trail-move/sources/record_tags.move +++ b/audit-trail-move/sources/record_tags.move @@ -72,6 +72,10 @@ public(package) fun remove_tag(self: &mut TagRegistry, tag: &String) { self.tag_map.remove(tag); } +public(package) fun tag_keys(self: &TagRegistry): vector { + iota::vec_map::keys(&self.tag_map) +} + /// Returns true when all provided `role_tags` (tags associated with a role) are contained in the `TagRegistry`. public(package) fun contains_all_role_tags( self: &TagRegistry, diff --git a/audit-trail-move/tests/create_audit_trail_tests.move b/audit-trail-move/tests/create_audit_trail_tests.move index ef57fe59..826ae41f 100644 --- a/audit-trail-move/tests/create_audit_trail_tests.move +++ b/audit-trail-move/tests/create_audit_trail_tests.move @@ -121,9 +121,9 @@ fun test_tag_admin_role_can_manage_available_record_tags() { ts::ctx(&mut scenario), ); - let available_tags = trail.tags(); - assert!(vec_map::size(available_tags) == 1, 0); - assert!(vec_map::contains(available_tags, &string::utf8(b"finance")), 1); + let available_tags = trail.tags().tag_keys(); + assert!(available_tags.length() == 1, 0); + assert!(available_tags.contains(&string::utf8(b"finance")), 1); trail.remove_record_tag( &tag_admin_cap, @@ -132,8 +132,8 @@ fun test_tag_admin_role_can_manage_available_record_tags() { ts::ctx(&mut scenario), ); - let available_tags = trail.tags(); - assert!(vec_map::size(available_tags) == 0, 0); + let available_tags = trail.tags().tag_keys(); + assert!(available_tags.length() == 0, 2); cleanup_capability_trail_and_clock(&scenario, tag_admin_cap, trail, clock); }; From 8b8db89e210e11e51190fa84dc9619782cfbce4d Mon Sep 17 00:00:00 2001 From: chrisgitiota Date: Mon, 23 Mar 2026 11:28:52 +0100 Subject: [PATCH 091/189] Fixed issue "is_in_use() is inverted" and removed several warnings --- audit-trail-move/sources/audit_trail.move | 3 +-- audit-trail-move/sources/record_tags.move | 2 +- audit-trail-move/tests/create_audit_trail_tests.move | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/audit-trail-move/sources/audit_trail.move b/audit-trail-move/sources/audit_trail.move index b7be786a..a61de673 100644 --- a/audit-trail-move/sources/audit_trail.move +++ b/audit-trail-move/sources/audit_trail.move @@ -25,7 +25,6 @@ use iota::{ clock::{Self, Clock}, event, linked_table::{Self, LinkedTable}, - vec_map::{Self, VecMap}, vec_set::VecSet }; use std::string::String; @@ -455,7 +454,7 @@ public fun delete_audit_trail( created_at: _, sequence_number: _, records, - mut tags, + tags, locking_config: _, roles, immutable_metadata: _, diff --git a/audit-trail-move/sources/record_tags.move b/audit-trail-move/sources/record_tags.move index 9025f229..76698d90 100644 --- a/audit-trail-move/sources/record_tags.move +++ b/audit-trail-move/sources/record_tags.move @@ -136,7 +136,7 @@ public(package) fun decrement_usage_count(self: &mut TagRegistry, tag: &String) /// Returns if the specified is in use. /// Returns false if the tag is not contained in the registry. public(package) fun is_in_use(self: &TagRegistry, tag: &String): bool { - self.usage_count(tag).borrow_with_default(&0) == 0 + (*self.usage_count(tag).borrow_with_default(&0)) > 0 } diff --git a/audit-trail-move/tests/create_audit_trail_tests.move b/audit-trail-move/tests/create_audit_trail_tests.move index 826ae41f..05268e12 100644 --- a/audit-trail-move/tests/create_audit_trail_tests.move +++ b/audit-trail-move/tests/create_audit_trail_tests.move @@ -16,7 +16,7 @@ use audit_trail::{ new_capability_for_address } }; -use iota::{clock, test_scenario as ts, vec_map}; +use iota::{clock, test_scenario as ts}; use std::string; use tf_components::timelock; From 49817ae69bf3564188a09ac0d36f8bcbb02c669f Mon Sep 17 00:00:00 2001 From: chrisgitiota Date: Mon, 23 Mar 2026 11:40:30 +0100 Subject: [PATCH 092/189] Renamed fun args `record_tags` to `role_tags` for `create_role` and `update_role` functions --- audit-trail-move/sources/audit_trail.move | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/audit-trail-move/sources/audit_trail.move b/audit-trail-move/sources/audit_trail.move index a61de673..59e3c948 100644 --- a/audit-trail-move/sources/audit_trail.move +++ b/audit-trail-move/sources/audit_trail.move @@ -636,26 +636,26 @@ public fun create_role( cap: &Capability, role: String, permissions: VecSet, - record_tags: Option, + role_tags: Option, clock: &Clock, ctx: &mut TxContext, ) { assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); - assert!(self.tags.contains_all_role_tags(&record_tags), ERecordTagNotDefined); + assert!(self.tags.contains_all_role_tags(&role_tags), ERecordTagNotDefined); role_map::create_role( self.access_mut(), cap, role, permissions, - copy record_tags, + copy role_tags, clock, ctx, ); - if (record_tags.is_some()) { - let tags = record_tags.borrow().tags().keys(); + if (role_tags.is_some()) { + let tags = role_tags.borrow().tags().keys(); let mut i = 0; let tag_count = tags.length(); @@ -672,20 +672,20 @@ public fun update_role_permissions( cap: &Capability, role: String, new_permissions: VecSet, - record_tags: Option, + role_tags: Option, clock: &Clock, ctx: &mut TxContext, ) { assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); - assert!(self.tags.contains_all_role_tags(&record_tags), ERecordTagNotDefined); + assert!(self.tags.contains_all_role_tags(&role_tags), ERecordTagNotDefined); let old_record_tags = *role_map::get_role_data(self.access(), &role); role_map::update_role( self.access_mut(), cap, &role, new_permissions, - copy record_tags, + copy role_tags, clock, ctx, ); @@ -701,8 +701,8 @@ public fun update_role_permissions( }; }; - if (record_tags.is_some()) { - let tags = record_tags.borrow().tags().keys(); + if (role_tags.is_some()) { + let tags = role_tags.borrow().tags().keys(); let mut i = 0; let tag_count = tags.length(); From b81d8a3b62be3fbf93766ace8e846b7a8edf3df4 Mon Sep 17 00:00:00 2001 From: Yasir Date: Tue, 24 Mar 2026 12:21:00 +0300 Subject: [PATCH 093/189] Update audit-trail-move/sources/audit_trail.move Co-authored-by: Christof Gerritsma --- audit-trail-move/sources/audit_trail.move | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/audit-trail-move/sources/audit_trail.move b/audit-trail-move/sources/audit_trail.move index 59e3c948..2fabf8ea 100644 --- a/audit-trail-move/sources/audit_trail.move +++ b/audit-trail-move/sources/audit_trail.move @@ -54,7 +54,7 @@ const ERecordTagAlreadyDefined: vector = b"The requested tag is already defined for this audit trail"; #[error] const ERecordTagInUse: vector = - b"The requested tag cannot be removed because it is already used by an existing record"; + b"The requested tag cannot be removed because it is already used by an existing record or role"; // ===== Constants ===== const INITIAL_ADMIN_ROLE_NAME: vector = b"Admin"; From b7a4968799b83cfaed86b6117c010752be49de85 Mon Sep 17 00:00:00 2001 From: Yasir Date: Tue, 24 Mar 2026 14:53:17 +0300 Subject: [PATCH 094/189] Update TfComponents dependency and fix Move tests --- audit-trail-move/Move.lock | 4 +-- audit-trail-move/Move.toml | 2 +- audit-trail-move/sources/audit_trail.move | 29 ++++++++++--------- audit-trail-move/sources/record_tags.move | 8 ++--- audit-trail-move/tests/capability_tests.move | 8 +---- .../tests/create_audit_trail_tests.move | 2 +- audit-trail-move/tests/locking_tests.move | 2 +- audit-trail-move/tests/metadata_tests.move | 11 +++++-- audit-trail-move/tests/record_tests.move | 2 +- audit-trail-move/tests/role_tests.move | 2 +- audit-trail-move/tests/test_utils.move | 12 ++++---- 11 files changed, 40 insertions(+), 42 deletions(-) diff --git a/audit-trail-move/Move.lock b/audit-trail-move/Move.lock index 4954376c..f9f740a2 100644 --- a/audit-trail-move/Move.lock +++ b/audit-trail-move/Move.lock @@ -2,7 +2,7 @@ [move] version = 3 -manifest_digest = "E2DCED5F45474DE4CA933A99DEAB29171001E7D699076C9B2528FA3A74FC2FCE" +manifest_digest = "BBDC635C3E5B1F977F4F12056411AADB62CD398CFCA75919B69BE3414CFC8393" deps_digest = "397E6A9F7A624706DBDFEE056CE88391A15876868FD18A88504DA74EB458D697" dependencies = [ { id = "Iota", name = "Iota" }, @@ -44,7 +44,7 @@ dependencies = [ [[move.package]] id = "TfComponents" -source = { local = "../../product-core/components_move" } +source = { git = "https://github.com/iotaledger/product-core.git", rev = "feat/tf-compoenents-dev", subdir = "components_move" } dependencies = [ { id = "Iota", name = "Iota" }, diff --git a/audit-trail-move/Move.toml b/audit-trail-move/Move.toml index 9914bbea..3c6e966c 100644 --- a/audit-trail-move/Move.toml +++ b/audit-trail-move/Move.toml @@ -3,7 +3,7 @@ name = "audit_trail" edition = "2024.beta" [dependencies] -TfComponents = { local = "../../product-core/components_move"} +TfComponents = { git = "https://github.com/iotaledger/product-core.git", subdir = "components_move", rev = "feat/tf-compoenents-dev" } [addresses] audit_trail = "0x0" diff --git a/audit-trail-move/sources/audit_trail.move b/audit-trail-move/sources/audit_trail.move index 2fabf8ea..2037e65b 100644 --- a/audit-trail-move/sources/audit_trail.move +++ b/audit-trail-move/sources/audit_trail.move @@ -21,12 +21,7 @@ use audit_trail::{ record::{Self, Record}, record_tags::{Self, RoleTags, TagRegistry} }; -use iota::{ - clock::{Self, Clock}, - event, - linked_table::{Self, LinkedTable}, - vec_set::VecSet -}; +use iota::{clock::{Self, Clock}, event, linked_table::{Self, LinkedTable}, vec_set::VecSet}; use std::string::String; use tf_components::{capability::Capability, role_map::{Self, RoleMap}, timelock::TimeLock}; @@ -406,7 +401,10 @@ public fun delete_records_batch( let (sequence_number, record) = self.records.pop_front(); if (record::tag(&record).is_some()) { - record_tags::decrement_usage_count(&mut self.tags, option::borrow(record::tag(&record))); + record_tags::decrement_usage_count( + &mut self.tags, + option::borrow(record::tag(&record)), + ); }; record.destroy(); @@ -788,7 +786,7 @@ public fun revoke_capability( cap_to_revoke, cap_to_revoke_valid_until, clock, - ctx + ctx, ); } @@ -850,7 +848,8 @@ public fun revoke_initial_admin_capability( cap_to_revoke, cap_to_revoke_valid_until, clock, - ctx); + ctx, + ); } /// Remove expired entries from the `revoked_capabilities` denylist. @@ -879,11 +878,13 @@ public fun cleanup_revoked_capabilities( ctx: &TxContext, ) { assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); - self.access_mut().cleanup_revoked_capabilities( - cap, - clock, - ctx, - ); + self + .access_mut() + .cleanup_revoked_capabilities( + cap, + clock, + ctx, + ); } // ===== Trail Query Functions ===== diff --git a/audit-trail-move/sources/record_tags.move b/audit-trail-move/sources/record_tags.move index 76698d90..c319660b 100644 --- a/audit-trail-move/sources/record_tags.move +++ b/audit-trail-move/sources/record_tags.move @@ -77,10 +77,7 @@ public(package) fun tag_keys(self: &TagRegistry): vector { } /// Returns true when all provided `role_tags` (tags associated with a role) are contained in the `TagRegistry`. -public(package) fun contains_all_role_tags( - self: &TagRegistry, - role_tags: &Option, -): bool { +public(package) fun contains_all_role_tags(self: &TagRegistry, role_tags: &Option): bool { if (!role_tags.is_some()) { return true }; @@ -139,7 +136,6 @@ public(package) fun is_in_use(self: &TagRegistry, tag: &String): bool { (*self.usage_count(tag).borrow_with_default(&0)) > 0 } - // ----------- RoleMap related ------- /// Returns true when the capability's role data allows the requested tag. @@ -155,4 +151,4 @@ public(package) fun role_allows( let tags = &option::borrow(role_tags).tags; iota::vec_set::contains(tags, tag) -} \ No newline at end of file +} diff --git a/audit-trail-move/tests/capability_tests.move b/audit-trail-move/tests/capability_tests.move index 793b5fcc..6aaad721 100644 --- a/audit-trail-move/tests/capability_tests.move +++ b/audit-trail-move/tests/capability_tests.move @@ -4,9 +4,9 @@ module audit_trail::capability_tests; use audit_trail::{ locking, - record::{Self, Data}, main::AuditTrail, permission, + record::{Self, Data}, test_utils::{ Self, setup_test_audit_trail, @@ -377,12 +377,6 @@ fun test_destroy_capability() { // Verify destroyed capability no longer exists ts::next_tx(&mut scenario, user2); { - let trail = ts::take_shared>(&scenario); - - // Only the initial admin capability should remain - assert!(trail.access().issued_capabilities().size() == 1, 8); - - ts::return_shared(trail); assert!(!ts::has_most_recent_for_sender(&scenario), 1); }; diff --git a/audit-trail-move/tests/create_audit_trail_tests.move b/audit-trail-move/tests/create_audit_trail_tests.move index 1004116a..29a727d2 100644 --- a/audit-trail-move/tests/create_audit_trail_tests.move +++ b/audit-trail-move/tests/create_audit_trail_tests.move @@ -4,9 +4,9 @@ module audit_trail::create_audit_trail_tests; use audit_trail::{ locking, - record::{Self, Data}, main::{Self, AuditTrail, initial_admin_role_name}, permission, + record::{Self, Data}, test_utils::{ setup_test_audit_trail, initial_time_for_testing, diff --git a/audit-trail-move/tests/locking_tests.move b/audit-trail-move/tests/locking_tests.move index 20a897e1..736f55d2 100644 --- a/audit-trail-move/tests/locking_tests.move +++ b/audit-trail-move/tests/locking_tests.move @@ -4,9 +4,9 @@ module audit_trail::locking_tests; use audit_trail::{ locking, - record::{Self, Data}, main::{Self, AuditTrail}, permission, + record::{Self, Data}, test_utils::{ Self, setup_test_audit_trail, diff --git a/audit-trail-move/tests/metadata_tests.move b/audit-trail-move/tests/metadata_tests.move index 10070682..62c7a4ea 100644 --- a/audit-trail-move/tests/metadata_tests.move +++ b/audit-trail-move/tests/metadata_tests.move @@ -13,8 +13,7 @@ use audit_trail::{ } }; use iota::test_scenario as ts; -use std::string; -use std::option::none; +use std::{option::none, string}; use tf_components::{capability::Capability, timelock}; // ===== Success Case Tests ===== @@ -275,7 +274,13 @@ fun test_update_metadata_revoked_capability() { trail .access_mut() - .revoke_capability(&admin_cap, metadata_cap.id(), none(), &clock, ts::ctx(&mut scenario)); + .revoke_capability( + &admin_cap, + metadata_cap.id(), + none(), + &clock, + ts::ctx(&mut scenario), + ); ts::return_to_address(metadata_admin_user, metadata_cap); cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); diff --git a/audit-trail-move/tests/record_tests.move b/audit-trail-move/tests/record_tests.move index faba8582..3d88caa4 100644 --- a/audit-trail-move/tests/record_tests.move +++ b/audit-trail-move/tests/record_tests.move @@ -4,9 +4,9 @@ module audit_trail::record_tests; use audit_trail::{ locking, - record::{Self, Data}, main::{Self, AuditTrail}, permission, + record::{Self, Data}, record_tags, test_utils::{ Self, diff --git a/audit-trail-move/tests/role_tests.move b/audit-trail-move/tests/role_tests.move index 8097c91a..a674988a 100644 --- a/audit-trail-move/tests/role_tests.move +++ b/audit-trail-move/tests/role_tests.move @@ -4,9 +4,9 @@ module audit_trail::role_tests; use audit_trail::{ locking, - record::{Self, Data}, main::{initial_admin_role_name, AuditTrail}, permission, + record::{Self, Data}, record_tags, test_utils::{ Self, diff --git a/audit-trail-move/tests/test_utils.move b/audit-trail-move/tests/test_utils.move index c0b64529..9c2f4d72 100644 --- a/audit-trail-move/tests/test_utils.move +++ b/audit-trail-move/tests/test_utils.move @@ -92,11 +92,13 @@ fun setup_test_audit_trail_impl( ); let initial_record = if (initial_data.is_some()) { - option::some(record::new_initial_record( - initial_data.destroy_some(), - option::none(), - option::none(), - )) + option::some( + record::new_initial_record( + initial_data.destroy_some(), + option::none(), + option::none(), + ), + ) } else { initial_data.destroy_none(); option::none() From 3c8082c2fb1c796510ee73b1a85c275d2e8d5187 Mon Sep 17 00:00:00 2001 From: Yasir Date: Tue, 24 Mar 2026 15:00:19 +0300 Subject: [PATCH 095/189] Compact initial record setup formatting --- audit-trail-rs/src/core/create/operations.rs | 6 ++-- audit-trail-rs/tests/e2e/locking.rs | 6 +--- audit-trail-rs/tests/e2e/trail.rs | 30 ++++---------------- 3 files changed, 8 insertions(+), 34 deletions(-) diff --git a/audit-trail-rs/src/core/create/operations.rs b/audit-trail-rs/src/core/create/operations.rs index c6e33484..6d3a54b9 100644 --- a/audit-trail-rs/src/core/create/operations.rs +++ b/audit-trail-rs/src/core/create/operations.rs @@ -47,10 +47,8 @@ impl CreateOps { let data_tag = initial_record.data.tag(); let initial_record_tag = InitialRecord::tag(audit_trail_package_id, &data_tag); let initial_record_arg = initial_record.into_ptb(&mut ptb, audit_trail_package_id)?; - let initial_record = - utils::option_to_move(Some(initial_record_arg), initial_record_tag, &mut ptb).map_err(|e| { - Error::InvalidArgument(format!("failed to build initial_record option: {e}")) - })?; + let initial_record = utils::option_to_move(Some(initial_record_arg), initial_record_tag, &mut ptb) + .map_err(|e| Error::InvalidArgument(format!("failed to build initial_record option: {e}")))?; let locking_config = locking_config.to_ptb(&mut ptb, audit_trail_package_id, tf_components_package_id)?; let immutable_metadata_tag = ImmutableMetadata::tag(audit_trail_package_id); diff --git a/audit-trail-rs/tests/e2e/locking.rs b/audit-trail-rs/tests/e2e/locking.rs index 0ee41054..7295a65f 100644 --- a/audit-trail-rs/tests/e2e/locking.rs +++ b/audit-trail-rs/tests/e2e/locking.rs @@ -250,11 +250,7 @@ async fn is_record_locked_supports_count_window_and_missing_sequence() -> anyhow let client = get_funded_test_client().await?; let trail_id = client .create_trail() - .with_initial_record(InitialRecord::new( - Data::text("trail-locking-status-e2e"), - None, - None, - )) + .with_initial_record(InitialRecord::new(Data::text("trail-locking-status-e2e"), None, None)) .with_locking_config(config_with_window(LockingWindow::CountBased { count: 2 })) .finish() .build_and_execute(&client) diff --git a/audit-trail-rs/tests/e2e/trail.rs b/audit-trail-rs/tests/e2e/trail.rs index 051e6655..02789797 100644 --- a/audit-trail-rs/tests/e2e/trail.rs +++ b/audit-trail-rs/tests/e2e/trail.rs @@ -24,11 +24,7 @@ async fn create_trail_with_default_builder_settings() -> anyhow::Result<()> { let created = client .create_trail() - .with_initial_record(InitialRecord::new( - Data::text("audit-trail-create-default"), - None, - None, - )) + .with_initial_record(InitialRecord::new(Data::text("audit-trail-create-default"), None, None)) .finish() .build_and_execute(&client) .await? @@ -116,11 +112,7 @@ async fn create_trail_with_custom_admin_address() -> anyhow::Result<()> { let created = client .create_trail() .with_admin(custom_admin) - .with_initial_record(InitialRecord::new( - Data::text("audit-trail-custom-admin"), - None, - None, - )) + .with_initial_record(InitialRecord::new(Data::text("audit-trail-custom-admin"), None, None)) .finish() .build_and_execute(&client) .await? @@ -305,11 +297,7 @@ async fn update_metadata_does_not_affect_immutable_metadata() -> anyhow::Result< let created = client .create_trail() - .with_initial_record(InitialRecord::new( - Data::text("trail-immutable-check-e2e"), - None, - None, - )) + .with_initial_record(InitialRecord::new(Data::text("trail-immutable-check-e2e"), None, None)) .with_trail_metadata(immutable.clone()) .with_updatable_metadata("mutable") .finish() @@ -367,11 +355,7 @@ async fn delete_records_batch_then_delete_audit_trail_roundtrip() -> anyhow::Res let client = get_funded_test_client().await?; let created = client .create_trail() - .with_initial_record(InitialRecord::new( - Data::text("trail-batch-delete-e2e"), - None, - None, - )) + .with_initial_record(InitialRecord::new(Data::text("trail-batch-delete-e2e"), None, None)) .with_locking_config(config_with_window(LockingWindow::TimeBased { seconds: 3600 })) .finish() .build_and_execute(&client) @@ -491,11 +475,7 @@ async fn remove_record_tag_rejects_role_only_usage() -> anyhow::Result<()> { let client = get_funded_test_client().await?; let created = client .create_trail() - .with_initial_record(InitialRecord::new( - Data::text("trail-tag-role-usage"), - None, - None, - )) + .with_initial_record(InitialRecord::new(Data::text("trail-tag-role-usage"), None, None)) .with_record_tags(["finance"]) .finish() .build_and_execute(&client) From d27d2c4453edb380b8e8f5189850b121c472b468 Mon Sep 17 00:00:00 2001 From: Yasir Date: Tue, 24 Mar 2026 17:45:54 +0300 Subject: [PATCH 096/189] chore: use tf components --- .../src/core/create/transactions.rs | 2 +- audit-trail-rs/src/core/locking/operations.rs | 6 +- audit-trail-rs/src/core/operations.rs | 70 ++++++++++++++++--- audit-trail-rs/src/core/records/operations.rs | 22 +++--- audit-trail-rs/src/core/types/role_map.rs | 15 ++-- audit-trail-rs/src/package.rs | 15 ++-- 6 files changed, 90 insertions(+), 40 deletions(-) diff --git a/audit-trail-rs/src/core/create/transactions.rs b/audit-trail-rs/src/core/create/transactions.rs index 4132ee33..a7d987c1 100644 --- a/audit-trail-rs/src/core/create/transactions.rs +++ b/audit-trail-rs/src/core/create/transactions.rs @@ -69,7 +69,7 @@ impl CreateTrail { .to_string(), ) })?; - let tf_components_package_id = package::tf_components_package_id(); + let tf_components_package_id = package::tf_components_package_id(client.network_name().as_ref())?; CreateOps::create_trail(CreateTrailArgs { audit_trail_package_id: client.package_id(), diff --git a/audit-trail-rs/src/core/locking/operations.rs b/audit-trail-rs/src/core/locking/operations.rs index ae04ee8c..f959c043 100644 --- a/audit-trail-rs/src/core/locking/operations.rs +++ b/audit-trail-rs/src/core/locking/operations.rs @@ -23,7 +23,7 @@ impl LockingOps { where C: CoreClientReadOnly + OptionalSync, { - let tf_components_package_id = package::tf_components_package_id(); + let tf_components_package_id = package::tf_components_package_id(client.network_name().as_ref())?; operations::build_trail_transaction( client, @@ -75,7 +75,7 @@ impl LockingOps { where C: CoreClientReadOnly + OptionalSync, { - let tf_components_package_id = package::tf_components_package_id(); + let tf_components_package_id = package::tf_components_package_id(client.network_name().as_ref())?; operations::build_trail_transaction( client, @@ -102,7 +102,7 @@ impl LockingOps { where C: CoreClientReadOnly + OptionalSync, { - let tf_components_package_id = package::tf_components_package_id(); + let tf_components_package_id = package::tf_components_package_id(client.network_name().as_ref())?; operations::build_trail_transaction( client, diff --git a/audit-trail-rs/src/core/operations.rs b/audit-trail-rs/src/core/operations.rs index 20fa30c0..ac2a3c99 100644 --- a/audit-trail-rs/src/core/operations.rs +++ b/audit-trail-rs/src/core/operations.rs @@ -4,7 +4,10 @@ use std::collections::HashSet; use std::str::FromStr; -use iota_interaction::rpc_types::{IotaData as _, IotaObjectDataOptions}; +use iota_interaction::move_types::language_storage::StructTag; +use iota_interaction::rpc_types::{ + IotaData as _, IotaObjectDataFilter, IotaObjectDataOptions, IotaObjectResponseQuery, IotaParsedData, +}; use iota_interaction::types::base_types::{IotaAddress, ObjectID, ObjectRef}; use iota_interaction::types::programmable_transaction_builder::ProgrammableTransactionBuilder; use iota_interaction::types::transaction::{Argument, ObjectArg, ProgrammableTransaction}; @@ -15,6 +18,7 @@ use product_common::core_client::CoreClientReadOnly; use crate::core::types::{Capability, OnChainAuditTrail, Permission}; use crate::core::utils; use crate::error::Error; +use crate::package; pub async fn get_audit_trail(trail_id: ObjectID, client: &C) -> Result where @@ -69,20 +73,16 @@ pub(crate) async fn find_capable_cap( where C: CoreClientReadOnly + OptionalSync, { - let valid_roles: HashSet<&String> = trail + let valid_roles: HashSet = trail .roles .roles .iter() .filter(|(_, role)| role.permissions.contains(&permission)) - .map(|(name, _)| name) + .map(|(name, _)| name.clone()) .collect(); - let cap: Capability = client - .find_object_for_address(owner, |cap: &Capability| { - cap.target_key == trail_id && valid_roles.contains(&cap.role) - }) - .await - .map_err(|e| Error::RpcError(e.to_string()))? + let cap = find_owned_capability(client, owner, |cap| cap.matches_target_and_role(trail_id, &valid_roles)) + .await? .ok_or_else(|| { Error::InvalidArgument(format!( "no capability with {:?} permission found for owner {owner} and trail {trail_id}", @@ -94,6 +94,58 @@ where utils::get_object_ref_by_id(client, &object_id).await } +pub(crate) async fn find_owned_capability( + client: &C, + owner: IotaAddress, + predicate: P, +) -> Result, Error> +where + C: CoreClientReadOnly + OptionalSync, + P: Fn(&Capability) -> bool + Send, +{ + let tf_components_package_id = package::tf_components_package_id(client.network_name().as_ref())?; + let capability_struct_tag: StructTag = Capability::type_tag(tf_components_package_id) + .to_string() + .parse() + .expect("capability type tag is a valid struct tag"); + let query = IotaObjectResponseQuery::new( + Some(IotaObjectDataFilter::StructType(capability_struct_tag)), + Some(IotaObjectDataOptions::default().with_content()), + ); + + let mut cursor = None; + loop { + let mut page = client + .client_adapter() + .read_api() + .get_owned_objects(owner, Some(query.clone()), cursor, Some(25)) + .await + .map_err(|e| Error::RpcError(e.to_string()))?; + + let maybe_cap = std::mem::take(&mut page.data) + .into_iter() + .filter_map(|res| res.data) + .filter_map(|data| data.content) + .filter_map(|obj_data| { + let IotaParsedData::MoveObject(move_object) = obj_data else { + unreachable!() + }; + serde_json::from_value(move_object.fields.to_json_value()).ok() + }) + .find(&predicate); + cursor = page.next_cursor; + + if maybe_cap.is_some() { + return Ok(maybe_cap); + } + if !page.has_next_page { + break; + } + } + + Ok(None) +} + pub(crate) async fn build_trail_transaction_with_cap_ref( client: &C, trail_id: ObjectID, diff --git a/audit-trail-rs/src/core/records/operations.rs b/audit-trail-rs/src/core/records/operations.rs index 6e203fc3..184354ad 100644 --- a/audit-trail-rs/src/core/records/operations.rs +++ b/audit-trail-rs/src/core/records/operations.rs @@ -162,18 +162,16 @@ where .map(|(name, _)| name.clone()) .collect::>(); - let cap = client - .find_object_for_address(owner, |cap: &crate::core::types::Capability| { - cap.target_key == trail_id && valid_roles.contains(&cap.role) - }) - .await - .map_err(|e| Error::RpcError(e.to_string()))? - .ok_or_else(|| { - Error::InvalidArgument(format!( - "no capability with {:?} permission and record tag '{tag}' found for owner {owner} and trail {trail_id}", - Permission::AddRecord - )) - })?; + let cap = operations::find_owned_capability(client, owner, |cap| { + cap.target_key == trail_id && valid_roles.contains(&cap.role) + }) + .await? + .ok_or_else(|| { + Error::InvalidArgument(format!( + "no capability with {:?} permission and record tag '{tag}' found for owner {owner} and trail {trail_id}", + Permission::AddRecord + )) + })?; let object_id = *cap.id.object_id(); utils::get_object_ref_by_id(client, &object_id).await diff --git a/audit-trail-rs/src/core/types/role_map.rs b/audit-trail-rs/src/core/types/role_map.rs index c0943cb7..9d310e33 100644 --- a/audit-trail-rs/src/core/types/role_map.rs +++ b/audit-trail-rs/src/core/types/role_map.rs @@ -4,20 +4,18 @@ use std::collections::{HashMap, HashSet}; use std::str::FromStr; +use iota_interaction::ident_str; use iota_interaction::types::TypeTag; use iota_interaction::types::base_types::{IotaAddress, ObjectID}; use iota_interaction::types::id::UID; use iota_interaction::types::programmable_transaction_builder::ProgrammableTransactionBuilder as Ptb; use iota_interaction::types::transaction::Argument; -use iota_interaction::{MoveType, ident_str}; use serde::{Deserialize, Serialize}; use super::permission::Permission; use crate::core::utils; use crate::core::utils::{deserialize_vec_map, deserialize_vec_set}; use crate::error::Error; -use crate::package; - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RoleMap { pub target_key: ObjectID, @@ -113,9 +111,12 @@ pub struct Capability { pub valid_until: Option, } -impl MoveType for Capability { - fn move_type(_: ObjectID) -> TypeTag { - let object_id = package::tf_components_package_id(); - TypeTag::from_str(format!("{object_id}::capability::Capability").as_str()).expect("failed to create type tag") +impl Capability { + pub(crate) fn type_tag(package_id: ObjectID) -> TypeTag { + TypeTag::from_str(format!("{package_id}::capability::Capability").as_str()).expect("failed to create type tag") + } + + pub(crate) fn matches_target_and_role(&self, trail_id: ObjectID, valid_roles: &HashSet) -> bool { + self.target_key == trail_id && valid_roles.contains(&self.role) } } diff --git a/audit-trail-rs/src/package.rs b/audit-trail-rs/src/package.rs index bb91b1b5..bd9de4aa 100644 --- a/audit-trail-rs/src/package.rs +++ b/audit-trail-rs/src/package.rs @@ -8,11 +8,11 @@ #![allow(dead_code)] -use std::str::FromStr; use std::sync::LazyLock; use iota_interaction::types::base_types::ObjectID; use product_common::package_registry::PackageRegistry; +use product_common::tf_components_registry; use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard, TryLockError}; type PackageRegistryLock = RwLockReadGuard<'static, PackageRegistry>; @@ -30,11 +30,6 @@ static AUDIT_TRAIL_PACKAGE_REGISTRY: LazyLock> = LazyLoc ) }); -/// Hardcoded TfComponents package ID used for timelock constructors. -/// -/// Update this value after publishing TfComponents. -const TF_COMPONENTS_PACKAGE_ID: &str = "0xefd478a0cca3cc660a46d2b55586fe799c71b331d939ee854272bc24ac16c07f"; - /// Returns a read lock to the package registry. pub(crate) async fn audit_trail_package_registry() -> PackageRegistryLock { AUDIT_TRAIL_PACKAGE_REGISTRY.read().await @@ -65,6 +60,10 @@ pub(crate) fn blocking_audit_trail_registry_mut() -> PackageRegistryLockMut { AUDIT_TRAIL_PACKAGE_REGISTRY.blocking_write() } -pub(crate) fn tf_components_package_id() -> ObjectID { - ObjectID::from_str(TF_COMPONENTS_PACKAGE_ID).expect("`TF_COMPONENTS_PACKAGE_ID` must be a valid ObjectID") +pub(crate) fn tf_components_package_id(network: &str) -> Result { + tf_components_registry::tf_components_package_id(network).ok_or_else(|| { + crate::error::Error::InvalidConfig(format!( + "no information for a published `TfComponents` package on network {network}" + )) + }) } From 9ec87bc5fb14c8c9d2097bb381177991f19da624 Mon Sep 17 00:00:00 2001 From: Yasir Date: Wed, 25 Mar 2026 09:28:56 +0300 Subject: [PATCH 097/189] chore: feature parity with rust library; add record tags to wasm --- .../examples/src/03_add_and_list_records.ts | 2 +- .../wasm/audit_trails_wasm/src/builder.rs | 20 +++-- bindings/wasm/audit_trails_wasm/src/client.rs | 5 ++ bindings/wasm/audit_trails_wasm/src/trail.rs | 62 ++++++++++++-- .../src/trail_handle/access.rs | 18 +++-- .../audit_trails_wasm/src/trail_handle/mod.rs | 9 +++ .../src/trail_handle/records.rs | 4 +- .../src/trail_handle/tags.rs | 50 ++++++++++++ bindings/wasm/audit_trails_wasm/src/types.rs | 80 ++++++++++++++++++- 9 files changed, 227 insertions(+), 23 deletions(-) create mode 100644 bindings/wasm/audit_trails_wasm/src/trail_handle/tags.rs diff --git a/bindings/wasm/audit_trails_wasm/examples/src/03_add_and_list_records.ts b/bindings/wasm/audit_trails_wasm/examples/src/03_add_and_list_records.ts index 3d7ce45b..6e8fea76 100644 --- a/bindings/wasm/audit_trails_wasm/examples/src/03_add_and_list_records.ts +++ b/bindings/wasm/audit_trails_wasm/examples/src/03_add_and_list_records.ts @@ -21,7 +21,7 @@ export async function addAndListRecords(): Promise { .withGasBudget(TEST_GAS_BUDGET) .buildAndExecute(client); - console.log("Added record sequence numbers:", addedString.output, addedBytes.output); + console.log("Added records:", addedString.output, addedBytes.output); const allRecords = await records.list(); const firstPage = await records.listPage(undefined, 2); diff --git a/bindings/wasm/audit_trails_wasm/src/builder.rs b/bindings/wasm/audit_trails_wasm/src/builder.rs index d9fd5042..5106fc46 100644 --- a/bindings/wasm/audit_trails_wasm/src/builder.rs +++ b/bindings/wasm/audit_trails_wasm/src/builder.rs @@ -9,7 +9,7 @@ use product_common::bindings::WasmIotaAddress; use wasm_bindgen::prelude::*; use crate::trail::WasmCreateTrail; -use crate::types::WasmLockingConfig; +use crate::types::{WasmLockingConfig, WasmRecordTags}; #[wasm_bindgen(js_name = AuditTrailBuilder, inspectable)] pub struct WasmAuditTrailBuilder(pub(crate) AuditTrailBuilder); @@ -17,13 +17,18 @@ pub struct WasmAuditTrailBuilder(pub(crate) AuditTrailBuilder); #[wasm_bindgen(js_class = AuditTrailBuilder)] impl WasmAuditTrailBuilder { #[wasm_bindgen(js_name = withInitialRecordString)] - pub fn with_initial_record_string(self, data: String, metadata: Option) -> Self { - Self(self.0.with_initial_record(data, metadata)) + pub fn with_initial_record_string(self, data: String, metadata: Option, tag: Option) -> Self { + Self(self.0.with_initial_record_parts(data, metadata, tag)) } #[wasm_bindgen(js_name = withInitialRecordBytes)] - pub fn with_initial_record_bytes(self, data: js_sys::Uint8Array, metadata: Option) -> Self { - Self(self.0.with_initial_record(data.to_vec(), metadata)) + pub fn with_initial_record_bytes( + self, + data: js_sys::Uint8Array, + metadata: Option, + tag: Option, + ) -> Self { + Self(self.0.with_initial_record_parts(data.to_vec(), metadata, tag)) } #[wasm_bindgen(js_name = withTrailMetadata)] @@ -41,6 +46,11 @@ impl WasmAuditTrailBuilder { Self(self.0.with_locking_config(config.into())) } + #[wasm_bindgen(js_name = withRecordTags)] + pub fn with_record_tags(self, record_tags: WasmRecordTags) -> Self { + Self(self.0.with_record_tags(record_tags.allowed_tags)) + } + #[wasm_bindgen(js_name = withAdmin)] pub fn with_admin(self, admin: WasmIotaAddress) -> Result { let admin = parse_wasm_iota_address(&admin)?; diff --git a/bindings/wasm/audit_trails_wasm/src/client.rs b/bindings/wasm/audit_trails_wasm/src/client.rs index 2b68b081..18f97bdf 100644 --- a/bindings/wasm/audit_trails_wasm/src/client.rs +++ b/bindings/wasm/audit_trails_wasm/src/client.rs @@ -63,6 +63,11 @@ impl WasmAuditTrailClient { self.0.network().to_string() } + #[wasm_bindgen(js_name = chainId)] + pub fn chain_id(&self) -> String { + self.0.chain_id().to_string() + } + #[wasm_bindgen(js_name = packageId)] pub fn package_id(&self) -> String { self.0.package_id().to_string() diff --git a/bindings/wasm/audit_trails_wasm/src/trail.rs b/bindings/wasm/audit_trails_wasm/src/trail.rs index ce1c7593..f8c303b0 100644 --- a/bindings/wasm/audit_trails_wasm/src/trail.rs +++ b/bindings/wasm/audit_trails_wasm/src/trail.rs @@ -10,6 +10,7 @@ use audit_trails::core::locking::{ UpdateDeleteRecordWindow, UpdateDeleteTrailLock, UpdateLockingConfig, UpdateWriteLock, }; use audit_trails::core::records::{AddRecord, DeleteRecord, DeleteRecordsBatch}; +use audit_trails::core::tags::{AddRecordTag, RemoveRecordTag}; use audit_trails::core::trail::{DeleteAuditTrail, Migrate, UpdateMetadata}; use audit_trails::core::types::{ AuditTrailDeleted, CapabilityDestroyed, CapabilityIssued, CapabilityRevoked, OnChainAuditTrail, RecordAdded, @@ -25,8 +26,8 @@ use wasm_bindgen::prelude::*; use crate::builder::WasmAuditTrailBuilder; use crate::types::{ WasmAuditTrailDeleted, WasmCapabilityDestroyed, WasmCapabilityIssued, WasmCapabilityRevoked, WasmEmpty, - WasmImmutableMetadata, WasmLinkedTable, WasmLockingConfig, WasmRoleCreated, WasmRoleMap, WasmRoleRemoved, - WasmRoleUpdated, + WasmImmutableMetadata, WasmLinkedTable, WasmLockingConfig, WasmRecordAdded, WasmRecordDeleted, WasmRecordTagEntry, + WasmRoleCreated, WasmRoleMap, WasmRoleRemoved, WasmRoleUpdated, }; #[wasm_bindgen(js_name = OnChainAuditTrail, inspectable)] @@ -69,6 +70,13 @@ impl WasmOnChainAuditTrail { self.0.records.clone().into() } + #[wasm_bindgen(getter)] + pub fn tags(&self) -> Vec { + let mut tags: Vec<_> = self.0.tags.clone().into_iter().map(Into::into).collect(); + tags.sort_unstable_by(|left, right| left.tag.cmp(&right.tag)); + tags + } + #[wasm_bindgen(getter)] pub fn roles(&self) -> WasmRoleMap { self.0.roles.clone().into() @@ -474,9 +482,9 @@ impl WasmAddRecord { wasm_effects: &WasmIotaTransactionBlockEffects, wasm_events: &WasmIotaTransactionBlockEvents, client: &WasmCoreClientReadOnly, - ) -> Result { + ) -> Result { let added: RecordAdded = apply_with_events(self.0, wasm_effects, wasm_events, client).await?; - Ok(added.sequence_number) + Ok(added.into()) } } @@ -496,9 +504,9 @@ impl WasmDeleteRecord { wasm_effects: &WasmIotaTransactionBlockEffects, wasm_events: &WasmIotaTransactionBlockEvents, client: &WasmCoreClientReadOnly, - ) -> Result { + ) -> Result { let deleted: RecordDeleted = apply_with_events(self.0, wasm_effects, wasm_events, client).await?; - Ok(deleted.sequence_number) + Ok(deleted.into()) } } @@ -522,3 +530,45 @@ impl WasmDeleteRecordsBatch { apply_with_events(self.0, wasm_effects, wasm_events, client).await } } + +#[wasm_bindgen(js_name = AddRecordTag, inspectable)] +pub struct WasmAddRecordTag(pub(crate) AddRecordTag); + +#[wasm_bindgen(js_class = AddRecordTag)] +impl WasmAddRecordTag { + #[wasm_bindgen(js_name = buildProgrammableTransaction)] + pub async fn build_programmable_transaction(&self, client: &WasmCoreClientReadOnly) -> Result> { + build_programmable_transaction(&self.0, client).await + } + + #[wasm_bindgen(js_name = applyWithEvents)] + pub async fn apply_with_events( + self, + wasm_effects: &WasmIotaTransactionBlockEffects, + wasm_events: &WasmIotaTransactionBlockEvents, + client: &WasmCoreClientReadOnly, + ) -> Result { + apply_with_events(self.0, wasm_effects, wasm_events, client).await + } +} + +#[wasm_bindgen(js_name = RemoveRecordTag, inspectable)] +pub struct WasmRemoveRecordTag(pub(crate) RemoveRecordTag); + +#[wasm_bindgen(js_class = RemoveRecordTag)] +impl WasmRemoveRecordTag { + #[wasm_bindgen(js_name = buildProgrammableTransaction)] + pub async fn build_programmable_transaction(&self, client: &WasmCoreClientReadOnly) -> Result> { + build_programmable_transaction(&self.0, client).await + } + + #[wasm_bindgen(js_name = applyWithEvents)] + pub async fn apply_with_events( + self, + wasm_effects: &WasmIotaTransactionBlockEffects, + wasm_events: &WasmIotaTransactionBlockEvents, + client: &WasmCoreClientReadOnly, + ) -> Result { + apply_with_events(self.0, wasm_effects, wasm_events, client).await + } +} diff --git a/bindings/wasm/audit_trails_wasm/src/trail_handle/access.rs b/bindings/wasm/audit_trails_wasm/src/trail_handle/access.rs index d6fab80d..f8f594f8 100644 --- a/bindings/wasm/audit_trails_wasm/src/trail_handle/access.rs +++ b/bindings/wasm/audit_trails_wasm/src/trail_handle/access.rs @@ -15,7 +15,7 @@ use crate::trail::{ WasmCreateRole, WasmDeleteRole, WasmDestroyCapability, WasmDestroyInitialAdminCapability, WasmIssueCapability, WasmRevokeCapability, WasmRevokeInitialAdminCapability, WasmUpdateRole, }; -use crate::types::{WasmCapabilityIssueOptions, WasmPermissionSet}; +use crate::types::{WasmCapabilityIssueOptions, WasmPermissionSet, WasmRecordTags}; #[derive(Clone)] #[wasm_bindgen(js_name = TrailAccess, inspectable)] @@ -126,13 +126,17 @@ impl WasmRoleHandle { } #[wasm_bindgen(unchecked_return_type = "TransactionBuilder")] - pub fn create(&self, permissions: WasmPermissionSet) -> Result { + pub fn create( + &self, + permissions: WasmPermissionSet, + record_tags: Option, + ) -> Result { let tx = self .require_write()? .trail(self.trail_id) .access() .for_role(self.name.clone()) - .create(permissions.into()) + .create(permissions.into(), record_tags.map(Into::into)) .into_inner(); Ok(into_transaction_builder(WasmCreateRole(tx))) } @@ -150,13 +154,17 @@ impl WasmRoleHandle { } #[wasm_bindgen(js_name = updatePermissions, unchecked_return_type = "TransactionBuilder")] - pub fn update_permissions(&self, permissions: WasmPermissionSet) -> Result { + pub fn update_permissions( + &self, + permissions: WasmPermissionSet, + record_tags: Option, + ) -> Result { let tx = self .require_write()? .trail(self.trail_id) .access() .for_role(self.name.clone()) - .update_permissions(permissions.into()) + .update_permissions(permissions.into(), record_tags.map(Into::into)) .into_inner(); Ok(into_transaction_builder(WasmUpdateRole(tx))) } diff --git a/bindings/wasm/audit_trails_wasm/src/trail_handle/mod.rs b/bindings/wasm/audit_trails_wasm/src/trail_handle/mod.rs index ed934517..1ed9b0ad 100644 --- a/bindings/wasm/audit_trails_wasm/src/trail_handle/mod.rs +++ b/bindings/wasm/audit_trails_wasm/src/trail_handle/mod.rs @@ -4,6 +4,7 @@ mod access; mod locking; mod records; +mod tags; use anyhow::anyhow; use audit_trails::{AuditTrailClient, AuditTrailClientReadOnly}; @@ -19,6 +20,7 @@ use crate::trail::{WasmDeleteAuditTrail, WasmMigrate, WasmOnChainAuditTrail, Was pub(crate) use access::WasmTrailAccess; pub(crate) use locking::WasmTrailLocking; pub(crate) use records::WasmTrailRecords; +pub(crate) use tags::WasmTrailTags; #[derive(Clone)] #[wasm_bindgen(js_name = AuditTrailHandle, inspectable)] @@ -112,4 +114,11 @@ impl WasmAuditTrailHandle { trail_id: self.trail_id, } } + + pub fn tags(&self) -> WasmTrailTags { + WasmTrailTags { + full: self.full.clone(), + trail_id: self.trail_id, + } + } } diff --git a/bindings/wasm/audit_trails_wasm/src/trail_handle/records.rs b/bindings/wasm/audit_trails_wasm/src/trail_handle/records.rs index 26150c2b..ada7e32c 100644 --- a/bindings/wasm/audit_trails_wasm/src/trail_handle/records.rs +++ b/bindings/wasm/audit_trails_wasm/src/trail_handle/records.rs @@ -110,12 +110,12 @@ impl WasmTrailRecords { } #[wasm_bindgen(unchecked_return_type = "TransactionBuilder")] - pub fn add(&self, data: WasmData, metadata: Option) -> Result { + pub fn add(&self, data: WasmData, metadata: Option, tag: Option) -> Result { let tx = self .require_write()? .trail(self.trail_id) .records() - .add(AuditTrailData::from(data), metadata) + .add(AuditTrailData::from(data), metadata, tag) .into_inner(); Ok(into_transaction_builder(WasmAddRecord(tx))) } diff --git a/bindings/wasm/audit_trails_wasm/src/trail_handle/tags.rs b/bindings/wasm/audit_trails_wasm/src/trail_handle/tags.rs new file mode 100644 index 00000000..a6fbc4be --- /dev/null +++ b/bindings/wasm/audit_trails_wasm/src/trail_handle/tags.rs @@ -0,0 +1,50 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use anyhow::anyhow; +use audit_trails::AuditTrailClient; +use iota_interaction::types::base_types::ObjectID; +use iota_interaction_ts::bindings::WasmTransactionSigner; +use iota_interaction_ts::wasm_error::{wasm_error, Result}; +use product_common::bindings::transaction::WasmTransactionBuilder; +use product_common::bindings::utils::into_transaction_builder; +use wasm_bindgen::prelude::*; + +use crate::trail::{WasmAddRecordTag, WasmRemoveRecordTag}; + +#[derive(Clone)] +#[wasm_bindgen(js_name = TrailTags, inspectable)] +pub struct WasmTrailTags { + pub(crate) full: Option>, + pub(crate) trail_id: ObjectID, +} + +impl WasmTrailTags { + fn require_write(&self) -> Result<&AuditTrailClient> { + self.full.as_ref().ok_or_else(|| { + wasm_error(anyhow!( + "TrailTags was created from a read-only client; this operation requires AuditTrailClient" + )) + }) + } +} + +#[wasm_bindgen(js_class = TrailTags)] +impl WasmTrailTags { + #[wasm_bindgen(unchecked_return_type = "TransactionBuilder")] + pub fn add(&self, tag: String) -> Result { + let tx = self.require_write()?.trail(self.trail_id).tags().add(tag).into_inner(); + Ok(into_transaction_builder(WasmAddRecordTag(tx))) + } + + #[wasm_bindgen(unchecked_return_type = "TransactionBuilder")] + pub fn remove(&self, tag: String) -> Result { + let tx = self + .require_write()? + .trail(self.trail_id) + .tags() + .remove(tag) + .into_inner(); + Ok(into_transaction_builder(WasmRemoveRecordTag(tx))) + } +} diff --git a/bindings/wasm/audit_trails_wasm/src/types.rs b/bindings/wasm/audit_trails_wasm/src/types.rs index 99ca9684..8065109d 100644 --- a/bindings/wasm/audit_trails_wasm/src/types.rs +++ b/bindings/wasm/audit_trails_wasm/src/types.rs @@ -6,7 +6,7 @@ use std::collections::{HashMap, HashSet}; use audit_trails::core::types::{ AuditTrailCreated, AuditTrailDeleted, Capability, CapabilityAdminPermissions, CapabilityDestroyed, CapabilityIssueOptions, CapabilityIssued, CapabilityRevoked, Data, ImmutableMetadata, LockingConfig, LockingWindow, - PaginatedRecord, Permission, PermissionSet, Record, RecordAdded, RecordCorrection, RecordDeleted, + PaginatedRecord, Permission, PermissionSet, Record, RecordAdded, RecordCorrection, RecordDeleted, RecordTags, Role, RoleAdminPermissions, RoleCreated, RoleMap, RoleRemoved, RoleUpdated, TimeLock, }; use iota_interaction::types::collection_types::LinkedTable; @@ -96,6 +96,8 @@ fn permission_sort_key(permission: Permission) -> u8 { Permission::UpdateMetadata => 14, Permission::DeleteMetadata => 15, Permission::Migrate => 16, + Permission::AddRecordTags => 17, + Permission::DeleteRecordTags => 18, } } @@ -105,18 +107,25 @@ fn sorted_permissions_from_set(permissions: HashSet) -> Vec) -> Vec { + let mut tags: Vec<_> = tags.into_iter().collect(); + tags.sort_unstable(); + tags +} + fn sorted_object_ids(ids: HashSet) -> Vec { let mut ids: Vec<_> = ids.into_iter().map(|id| id.to_string()).collect(); ids.sort_unstable(); ids } -fn sorted_role_entries(roles: HashMap>) -> Vec { +fn sorted_role_entries(roles: HashMap) -> Vec { let mut roles: Vec<_> = roles .into_iter() - .map(|(name, permissions)| WasmRolePermissionsEntry { + .map(|(name, role)| WasmRolePermissionsEntry { name, - permissions: sorted_permissions_from_set(permissions), + permissions: sorted_permissions_from_set(role.permissions), + record_tags: role.data.map(Into::into), }) .collect(); roles.sort_unstable_by(|left, right| left.name.cmp(&right.name)); @@ -143,6 +152,8 @@ pub enum WasmPermission { UpdateMetadata, DeleteMetadata, Migrate, + AddRecordTags, + DeleteRecordTags, } impl From for WasmPermission { @@ -165,6 +176,8 @@ impl From for WasmPermission { Permission::UpdateMetadata => Self::UpdateMetadata, Permission::DeleteMetadata => Self::DeleteMetadata, Permission::Migrate => Self::Migrate, + Permission::AddRecordTags => Self::AddRecordTags, + Permission::DeleteRecordTags => Self::DeleteRecordTags, } } } @@ -189,6 +202,8 @@ impl From for Permission { WasmPermission::UpdateMetadata => Self::UpdateMetadata, WasmPermission::DeleteMetadata => Self::DeleteMetadata, WasmPermission::Migrate => Self::Migrate, + WasmPermission::AddRecordTags => Self::AddRecordTags, + WasmPermission::DeleteRecordTags => Self::DeleteRecordTags, } } } @@ -235,6 +250,11 @@ impl WasmPermissionSet { pub fn metadata_admin_permissions() -> Self { PermissionSet::metadata_admin_permissions().into() } + + #[wasm_bindgen(js_name = tagAdminPermissions)] + pub fn tag_admin_permissions() -> Self { + PermissionSet::tag_admin_permissions().into() + } } impl From for WasmPermissionSet { @@ -312,6 +332,56 @@ impl From for WasmCapabilityAdminPermissions { pub struct WasmRolePermissionsEntry { pub name: String, pub permissions: Vec, + #[wasm_bindgen(js_name = recordTags)] + pub record_tags: Option, +} + +#[wasm_bindgen(js_name = RecordTags, getter_with_clone, inspectable)] +#[derive(Clone, Serialize, Deserialize)] +pub struct WasmRecordTags { + #[wasm_bindgen(js_name = allowedTags)] + pub allowed_tags: Vec, +} + +#[wasm_bindgen(js_class = RecordTags)] +impl WasmRecordTags { + #[wasm_bindgen(constructor)] + pub fn new(allowed_tags: Vec) -> Self { + let mut allowed_tags = allowed_tags; + allowed_tags.sort_unstable(); + allowed_tags.dedup(); + Self { allowed_tags } + } +} + +impl From for WasmRecordTags { + fn from(value: RecordTags) -> Self { + Self { + allowed_tags: sorted_tag_names(value.allowed_tags), + } + } +} + +impl From for RecordTags { + fn from(value: WasmRecordTags) -> Self { + Self { + allowed_tags: value.allowed_tags.into_iter().collect(), + } + } +} + +#[wasm_bindgen(js_name = RecordTagEntry, getter_with_clone, inspectable)] +#[derive(Clone, Serialize, Deserialize)] +pub struct WasmRecordTagEntry { + pub tag: String, + #[wasm_bindgen(js_name = usageCount)] + pub usage_count: u64, +} + +impl From<(String, u64)> for WasmRecordTagEntry { + fn from((tag, usage_count): (String, u64)) -> Self { + Self { tag, usage_count } + } } #[wasm_bindgen(js_name = RoleMap, getter_with_clone, inspectable)] @@ -854,6 +924,7 @@ impl From for RecordCorrection { pub struct WasmRecord { pub data: WasmData, pub metadata: Option, + pub tag: Option, #[wasm_bindgen(js_name = sequenceNumber)] pub sequence_number: u64, #[wasm_bindgen(js_name = addedBy)] @@ -868,6 +939,7 @@ impl From> for WasmRecord { Self { data: value.data.into(), metadata: value.metadata, + tag: value.tag, sequence_number: value.sequence_number, added_by: value.added_by.to_string(), added_at: value.added_at, From 0d5c0202c60356a3b921033e3008de07e2aa4c40 Mon Sep 17 00:00:00 2001 From: Yasir Date: Wed, 25 Mar 2026 14:18:53 +0300 Subject: [PATCH 098/189] chore: update the rust library to be upto par with contract --- audit-trail-move/sources/audit_trail.move | 9 +- audit-trail-rs/src/core/access/mod.rs | 57 +++++++--- audit-trail-rs/src/core/access/operations.rs | 86 ++++++++------ .../src/core/access/transactions.rs | 107 +++++++++++++++--- audit-trail-rs/src/core/types/audit_trail.rs | 31 ++++- audit-trail-rs/src/core/types/event.rs | 38 ++++++- audit-trail-rs/src/core/types/role_map.rs | 30 ++--- audit-trail-rs/tests/e2e/access.rs | 78 +++++++++++-- audit-trail-rs/tests/e2e/client.rs | 10 +- audit-trail-rs/tests/e2e/records.rs | 6 +- audit-trail-rs/tests/e2e/trail.rs | 8 +- 11 files changed, 349 insertions(+), 111 deletions(-) diff --git a/audit-trail-move/sources/audit_trail.move b/audit-trail-move/sources/audit_trail.move index 2037e65b..ddc15a36 100644 --- a/audit-trail-move/sources/audit_trail.move +++ b/audit-trail-move/sources/audit_trail.move @@ -81,7 +81,7 @@ public struct AuditTrail has key, store { /// LinkedTable mapping sequence numbers to records records: LinkedTable>, /// Canonical list of tags that may be attached to records in this trail with their combined usage counts - tags: record_tags::TagRegistry, + tags: TagRegistry, /// Deletion locking rules locking_config: LockingConfig, /// A list of role definitions consisting of a unique role specifier and a list of associated permissions @@ -179,13 +179,6 @@ public fun create( linked_table::push_back(&mut records, 0, record); sequence_number = 1; - - event::emit(RecordAdded { - trail_id, - sequence_number: 0, - added_by: creator, - timestamp, - }); } else { initial_record.destroy_none(); }; diff --git a/audit-trail-rs/src/core/access/mod.rs b/audit-trail-rs/src/core/access/mod.rs index 912de608..398c9c90 100644 --- a/audit-trail-rs/src/core/access/mod.rs +++ b/audit-trail-rs/src/core/access/mod.rs @@ -8,14 +8,14 @@ use product_common::transaction::transaction_builder::TransactionBuilder; use secret_storage::Signer; use crate::core::trail::AuditTrailFull; -use crate::core::types::{CapabilityIssueOptions, PermissionSet, RecordTags}; +use crate::core::types::{CapabilityIssueOptions, PermissionSet, RoleTags}; mod operations; mod transactions; pub use transactions::{ - CreateRole, DeleteRole, DestroyCapability, DestroyInitialAdminCapability, IssueCapability, RevokeCapability, - RevokeInitialAdminCapability, UpdateRole, + CleanupRevokedCapabilities, CreateRole, DeleteRole, DestroyCapability, DestroyInitialAdminCapability, + IssueCapability, RevokeCapability, RevokeInitialAdminCapability, UpdateRole, }; #[derive(Debug, Clone)] @@ -35,13 +35,24 @@ impl<'a, C> TrailAccess<'a, C> { } /// Revokes an issued capability. - pub fn revoke_capability(&self, capability_id: ObjectID) -> TransactionBuilder + /// + /// Pass the capability's `valid_until` value when it is known so the denylist entry matches the on-chain cleanup model. + pub fn revoke_capability( + &self, + capability_id: ObjectID, + capability_valid_until: Option, + ) -> TransactionBuilder where C: AuditTrailFull + CoreClient, S: Signer + OptionalSync, { let owner = self.client.sender_address(); - TransactionBuilder::new(RevokeCapability::new(self.trail_id, owner, capability_id)) + TransactionBuilder::new(RevokeCapability::new( + self.trail_id, + owner, + capability_id, + capability_valid_until, + )) } /// Destroys a capability object. @@ -67,16 +78,34 @@ impl<'a, C> TrailAccess<'a, C> { } /// Revokes an initial admin capability by ID. + /// + /// Pass the capability's `valid_until` value when it is known so the denylist entry matches the on-chain cleanup model. pub fn revoke_initial_admin_capability( &self, capability_id: ObjectID, + capability_valid_until: Option, ) -> TransactionBuilder where C: AuditTrailFull + CoreClient, S: Signer + OptionalSync, { let owner = self.client.sender_address(); - TransactionBuilder::new(RevokeInitialAdminCapability::new(self.trail_id, owner, capability_id)) + TransactionBuilder::new(RevokeInitialAdminCapability::new( + self.trail_id, + owner, + capability_id, + capability_valid_until, + )) + } + + /// Removes expired entries from the revoked-capability denylist. + pub fn cleanup_revoked_capabilities(&self) -> TransactionBuilder + where + C: AuditTrailFull + CoreClient, + S: Signer + OptionalSync, + { + let owner = self.client.sender_address(); + TransactionBuilder::new(CleanupRevokedCapabilities::new(self.trail_id, owner)) } } @@ -96,12 +125,8 @@ impl<'a, C> RoleHandle<'a, C> { &self.name } - /// Creates this role with the provided permissions and optional record-tag access rules. - pub fn create( - &self, - permissions: PermissionSet, - record_tags: Option, - ) -> TransactionBuilder + /// Creates this role with the provided permissions and optional role-tag access rules. + pub fn create(&self, permissions: PermissionSet, role_tags: Option) -> TransactionBuilder where C: AuditTrailFull + CoreClient, S: Signer + OptionalSync, @@ -112,7 +137,7 @@ impl<'a, C> RoleHandle<'a, C> { owner, self.name.clone(), permissions, - record_tags, + role_tags, )) } @@ -126,11 +151,11 @@ impl<'a, C> RoleHandle<'a, C> { TransactionBuilder::new(IssueCapability::new(self.trail_id, owner, self.name.clone(), options)) } - /// Updates permissions and record-tag access rules for this role. + /// Updates permissions and role-tag access rules for this role. pub fn update_permissions( &self, permissions: PermissionSet, - record_tags: Option, + role_tags: Option, ) -> TransactionBuilder where C: AuditTrailFull + CoreClient, @@ -142,7 +167,7 @@ impl<'a, C> RoleHandle<'a, C> { owner, self.name.clone(), permissions, - record_tags, + role_tags, )) } diff --git a/audit-trail-rs/src/core/access/operations.rs b/audit-trail-rs/src/core/access/operations.rs index 08b7a4d6..80705ab5 100644 --- a/audit-trail-rs/src/core/access/operations.rs +++ b/audit-trail-rs/src/core/access/operations.rs @@ -6,7 +6,7 @@ use iota_interaction::types::transaction::{ObjectArg, ProgrammableTransaction}; use iota_interaction::{OptionalSync, ident_str}; use product_common::core_client::CoreClientReadOnly; -use crate::core::types::{CapabilityIssueOptions, Permission, PermissionSet, RecordTags}; +use crate::core::types::{CapabilityIssueOptions, Permission, PermissionSet, RoleTags}; use crate::core::{operations, utils}; use crate::error::Error; @@ -19,12 +19,12 @@ impl AccessOps { owner: IotaAddress, name: String, permissions: PermissionSet, - record_tags: Option, + role_tags: Option, ) -> Result where C: CoreClientReadOnly + OptionalSync, { - assert_record_tags_defined(client, trail_id, &record_tags).await?; + assert_role_tags_defined(client, trail_id, &role_tags).await?; operations::build_trail_transaction( client, @@ -42,19 +42,19 @@ impl AccessOps { vec![], vec![perms_vec], ); - let record_tags_arg = match record_tags { - Some(record_tags) => { - let record_tags_arg = record_tags.to_ptb(ptb, client.package_id())?; + let role_tags_arg = match role_tags { + Some(role_tags) => { + let role_tags_arg = role_tags.to_ptb(ptb, client.package_id())?; - utils::option_to_move(Some(record_tags_arg), RecordTags::tag(client.package_id()), ptb) - .map_err(|e| Error::InvalidArgument(format!("failed to build record_tags option: {e}")))? + utils::option_to_move(Some(role_tags_arg), RoleTags::tag(client.package_id()), ptb) + .map_err(|e| Error::InvalidArgument(format!("failed to build role_tags option: {e}")))? } - None => utils::option_to_move(None, RecordTags::tag(client.package_id()), ptb) - .map_err(|e| Error::InvalidArgument(format!("failed to build record_tags option: {e}")))?, + None => utils::option_to_move(None, RoleTags::tag(client.package_id()), ptb) + .map_err(|e| Error::InvalidArgument(format!("failed to build role_tags option: {e}")))?, }; let clock = utils::get_clock_ref(ptb); - Ok(vec![role, perms, record_tags_arg, clock]) + Ok(vec![role, perms, role_tags_arg, clock]) }, ) .await @@ -66,12 +66,12 @@ impl AccessOps { owner: IotaAddress, name: String, permissions: PermissionSet, - record_tags: Option, + role_tags: Option, ) -> Result where C: CoreClientReadOnly + OptionalSync, { - assert_record_tags_defined(client, trail_id, &record_tags).await?; + assert_role_tags_defined(client, trail_id, &role_tags).await?; operations::build_trail_transaction( client, @@ -90,19 +90,19 @@ impl AccessOps { vec![], vec![perms_vec], ); - let record_tags_arg = match record_tags { - Some(record_tags) => { - let record_tags_arg = record_tags.to_ptb(ptb, client.package_id())?; - utils::option_to_move(Some(record_tags_arg), RecordTags::tag(client.package_id()), ptb) - .map_err(|e| Error::InvalidArgument(format!("failed to build record_tags option: {e}")))? + let role_tags_arg = match role_tags { + Some(role_tags) => { + let role_tags_arg = role_tags.to_ptb(ptb, client.package_id())?; + utils::option_to_move(Some(role_tags_arg), RoleTags::tag(client.package_id()), ptb) + .map_err(|e| Error::InvalidArgument(format!("failed to build role_tags option: {e}")))? } - None => utils::option_to_move(None, RecordTags::tag(client.package_id()), ptb) - .map_err(|e| Error::InvalidArgument(format!("failed to build record_tags option: {e}")))?, + None => utils::option_to_move(None, RoleTags::tag(client.package_id()), ptb) + .map_err(|e| Error::InvalidArgument(format!("failed to build role_tags option: {e}")))?, }; let clock = utils::get_clock_ref(ptb); - Ok(vec![role, perms, record_tags_arg, clock]) + Ok(vec![role, perms, role_tags_arg, clock]) }, ) .await @@ -167,6 +167,7 @@ impl AccessOps { trail_id: ObjectID, owner: IotaAddress, capability_id: ObjectID, + capability_valid_until: Option, ) -> Result where C: CoreClientReadOnly + OptionalSync, @@ -179,9 +180,10 @@ impl AccessOps { "revoke_capability", |ptb, _| { let cap = utils::ptb_pure(ptb, "capability_id", capability_id)?; + let valid_until = utils::ptb_pure(ptb, "capability_valid_until", capability_valid_until)?; let clock = utils::get_clock_ref(ptb); - Ok(vec![cap, clock]) + Ok(vec![cap, valid_until, clock]) }, ) .await @@ -240,6 +242,7 @@ impl AccessOps { trail_id: ObjectID, owner: IotaAddress, capability_id: ObjectID, + capability_valid_until: Option, ) -> Result where C: CoreClientReadOnly + OptionalSync, @@ -252,30 +255,49 @@ impl AccessOps { "revoke_initial_admin_capability", |ptb, _| { let cap = utils::ptb_pure(ptb, "capability_id", capability_id)?; + let valid_until = utils::ptb_pure(ptb, "capability_valid_until", capability_valid_until)?; let clock = utils::get_clock_ref(ptb); - Ok(vec![cap, clock]) + Ok(vec![cap, valid_until, clock]) + }, + ) + .await + } + + pub(super) async fn cleanup_revoked_capabilities( + client: &C, + trail_id: ObjectID, + owner: IotaAddress, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + operations::build_trail_transaction( + client, + trail_id, + owner, + Permission::RevokeCapabilities, + "cleanup_revoked_capabilities", + |ptb, _| { + let clock = utils::get_clock_ref(ptb); + Ok(vec![clock]) }, ) .await } } -async fn assert_record_tags_defined( - client: &C, - trail_id: ObjectID, - record_tags: &Option, -) -> Result<(), Error> +async fn assert_role_tags_defined(client: &C, trail_id: ObjectID, role_tags: &Option) -> Result<(), Error> where C: CoreClientReadOnly + OptionalSync, { - let Some(record_tags) = record_tags else { + let Some(role_tags) = role_tags else { return Ok(()); }; let trail = operations::get_audit_trail(trail_id, client).await?; - let undefined_tags = record_tags - .allowed_tags + let undefined_tags = role_tags + .tags .iter() .filter(|tag| !trail.tags.contains_key(*tag)) .cloned() @@ -285,7 +307,7 @@ where Ok(()) } else { Err(Error::InvalidArgument(format!( - "record tags {:?} are not defined for trail {trail_id}", + "role tags {:?} are not defined for trail {trail_id}", undefined_tags ))) } diff --git a/audit-trail-rs/src/core/access/transactions.rs b/audit-trail-rs/src/core/access/transactions.rs index cbf5cddc..84866a5a 100644 --- a/audit-trail-rs/src/core/access/transactions.rs +++ b/audit-trail-rs/src/core/access/transactions.rs @@ -12,8 +12,8 @@ use tokio::sync::OnceCell; use super::operations::AccessOps; use crate::core::types::{ - CapabilityDestroyed, CapabilityIssueOptions, CapabilityIssued, CapabilityRevoked, Event, PermissionSet, RecordTags, - RoleCreated, RoleRemoved, RoleUpdated, + CapabilityDestroyed, CapabilityIssueOptions, CapabilityIssued, CapabilityRevoked, Event, PermissionSet, + RoleCreated, RoleDeleted, RoleTags, RoleUpdated, }; use crate::error::Error; @@ -25,7 +25,7 @@ pub struct CreateRole { owner: IotaAddress, name: String, permissions: PermissionSet, - record_tags: Option, + role_tags: Option, cached_ptb: OnceCell, } @@ -35,14 +35,14 @@ impl CreateRole { owner: IotaAddress, name: String, permissions: PermissionSet, - record_tags: Option, + role_tags: Option, ) -> Self { Self { trail_id, owner, name, permissions, - record_tags, + role_tags, cached_ptb: OnceCell::new(), } } @@ -57,7 +57,7 @@ impl CreateRole { self.owner, self.name.clone(), self.permissions.clone(), - self.record_tags.clone(), + self.role_tags.clone(), ) .await } @@ -108,7 +108,7 @@ pub struct UpdateRole { owner: IotaAddress, name: String, permissions: PermissionSet, - record_tags: Option, + role_tags: Option, cached_ptb: OnceCell, } @@ -118,14 +118,14 @@ impl UpdateRole { owner: IotaAddress, name: String, permissions: PermissionSet, - record_tags: Option, + role_tags: Option, ) -> Self { Self { trail_id, owner, name, permissions, - record_tags, + role_tags, cached_ptb: OnceCell::new(), } } @@ -140,7 +140,7 @@ impl UpdateRole { self.owner, self.name.clone(), self.permissions.clone(), - self.record_tags.clone(), + self.role_tags.clone(), ) .await } @@ -215,7 +215,7 @@ impl DeleteRole { #[cfg_attr(feature = "send-sync", async_trait)] impl Transaction for DeleteRole { type Error = Error; - type Output = RoleRemoved; + type Output = RoleDeleted; async fn build_programmable_transaction(&self, client: &C) -> Result where @@ -236,8 +236,8 @@ impl Transaction for DeleteRole { let event = events .data .iter() - .find_map(|data| serde_json::from_value::>(data.parsed_json.clone()).ok()) - .ok_or_else(|| Error::UnexpectedApiResponse("RoleRemoved event not found".to_string()))?; + .find_map(|data| serde_json::from_value::>(data.parsed_json.clone()).ok()) + .ok_or_else(|| Error::UnexpectedApiResponse("RoleDeleted event not found".to_string()))?; Ok(event.data) } @@ -329,15 +329,22 @@ pub struct RevokeCapability { trail_id: ObjectID, owner: IotaAddress, capability_id: ObjectID, + capability_valid_until: Option, cached_ptb: OnceCell, } impl RevokeCapability { - pub fn new(trail_id: ObjectID, owner: IotaAddress, capability_id: ObjectID) -> Self { + pub fn new( + trail_id: ObjectID, + owner: IotaAddress, + capability_id: ObjectID, + capability_valid_until: Option, + ) -> Self { Self { trail_id, owner, capability_id, + capability_valid_until, cached_ptb: OnceCell::new(), } } @@ -346,7 +353,14 @@ impl RevokeCapability { where C: CoreClientReadOnly + OptionalSync, { - AccessOps::revoke_capability(client, self.trail_id, self.owner, self.capability_id).await + AccessOps::revoke_capability( + client, + self.trail_id, + self.owner, + self.capability_id, + self.capability_valid_until, + ) + .await } } @@ -526,15 +540,22 @@ pub struct RevokeInitialAdminCapability { trail_id: ObjectID, owner: IotaAddress, capability_id: ObjectID, + capability_valid_until: Option, cached_ptb: OnceCell, } impl RevokeInitialAdminCapability { - pub fn new(trail_id: ObjectID, owner: IotaAddress, capability_id: ObjectID) -> Self { + pub fn new( + trail_id: ObjectID, + owner: IotaAddress, + capability_id: ObjectID, + capability_valid_until: Option, + ) -> Self { Self { trail_id, owner, capability_id, + capability_valid_until, cached_ptb: OnceCell::new(), } } @@ -543,7 +564,14 @@ impl RevokeInitialAdminCapability { where C: CoreClientReadOnly + OptionalSync, { - AccessOps::revoke_initial_admin_capability(client, self.trail_id, self.owner, self.capability_id).await + AccessOps::revoke_initial_admin_capability( + client, + self.trail_id, + self.owner, + self.capability_id, + self.capability_valid_until, + ) + .await } } @@ -585,3 +613,48 @@ impl Transaction for RevokeInitialAdminCapability { unreachable!() } } + +#[derive(Debug, Clone)] +pub struct CleanupRevokedCapabilities { + trail_id: ObjectID, + owner: IotaAddress, + cached_ptb: OnceCell, +} + +impl CleanupRevokedCapabilities { + pub fn new(trail_id: ObjectID, owner: IotaAddress) -> Self { + Self { + trail_id, + owner, + cached_ptb: OnceCell::new(), + } + } + + async fn make_ptb(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + AccessOps::cleanup_revoked_capabilities(client, self.trail_id, self.owner).await + } +} + +#[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] +#[cfg_attr(feature = "send-sync", async_trait)] +impl Transaction for CleanupRevokedCapabilities { + type Error = Error; + type Output = (); + + async fn build_programmable_transaction(&self, client: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() + } + + async fn apply(self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + Ok(()) + } +} diff --git a/audit-trail-rs/src/core/types/audit_trail.rs b/audit-trail-rs/src/core/types/audit_trail.rs index 4d55a6db..9c5b175e 100644 --- a/audit-trail-rs/src/core/types/audit_trail.rs +++ b/audit-trail-rs/src/core/types/audit_trail.rs @@ -18,6 +18,34 @@ use super::role_map::RoleMap; use crate::core::utils::{self, deserialize_vec_map}; use crate::error::Error; +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TagRegistry { + #[serde(deserialize_with = "deserialize_vec_map")] + pub tag_map: HashMap, +} + +impl TagRegistry { + pub fn len(&self) -> usize { + self.tag_map.len() + } + + pub fn is_empty(&self) -> bool { + self.tag_map.is_empty() + } + + pub fn contains_key(&self, tag: &str) -> bool { + self.tag_map.contains_key(tag) + } + + pub fn get(&self, tag: &str) -> Option<&u64> { + self.tag_map.get(tag) + } + + pub fn iter(&self) -> impl Iterator { + self.tag_map.iter() + } +} + /// An audit trail stored on-chain. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct OnChainAuditTrail { @@ -26,8 +54,7 @@ pub struct OnChainAuditTrail { pub created_at: u64, pub sequence_number: u64, pub records: LinkedTable, - #[serde(deserialize_with = "deserialize_vec_map")] - pub tags: HashMap, + pub tags: TagRegistry, pub locking_config: LockingConfig, pub roles: RoleMap, pub immutable_metadata: Option, diff --git a/audit-trail-rs/src/core/types/event.rs b/audit-trail-rs/src/core/types/event.rs index 5a73ca65..d04f0646 100644 --- a/audit-trail-rs/src/core/types/event.rs +++ b/audit-trail-rs/src/core/types/event.rs @@ -5,6 +5,9 @@ use iota_interaction::types::base_types::{IotaAddress, ObjectID}; use serde::{Deserialize, Serialize}; use serde_aux::field_attributes::{deserialize_number_from_string, deserialize_option_number_from_string}; +use super::{PermissionSet, RoleTags}; +use crate::core::utils::deserialize_vec_set; + /// Generic wrapper for audit trail events. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Event { @@ -63,12 +66,20 @@ pub struct CapabilityIssued { pub struct CapabilityDestroyed { pub target_key: ObjectID, pub capability_id: ObjectID, + pub role: String, + pub issued_to: Option, + #[serde(deserialize_with = "deserialize_option_number_from_string")] + pub valid_from: Option, + #[serde(deserialize_with = "deserialize_option_number_from_string")] + pub valid_until: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CapabilityRevoked { pub target_key: ObjectID, pub capability_id: ObjectID, + #[serde(deserialize_with = "deserialize_number_from_string")] + pub valid_until: u64, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -76,6 +87,12 @@ pub struct RoleCreated { #[serde(rename = "target_key")] pub trail_id: ObjectID, pub role: String, + #[serde(deserialize_with = "deserialize_permission_set")] + pub permissions: PermissionSet, + pub data: Option, + pub created_by: IotaAddress, + #[serde(deserialize_with = "deserialize_number_from_string")] + pub timestamp: u64, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -83,11 +100,30 @@ pub struct RoleUpdated { #[serde(rename = "target_key")] pub trail_id: ObjectID, pub role: String, + #[serde(rename = "new_permissions", deserialize_with = "deserialize_permission_set")] + pub permissions: PermissionSet, + #[serde(rename = "new_data")] + pub data: Option, + pub updated_by: IotaAddress, + #[serde(deserialize_with = "deserialize_number_from_string")] + pub timestamp: u64, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct RoleRemoved { +pub struct RoleDeleted { #[serde(rename = "target_key")] pub trail_id: ObjectID, pub role: String, + pub deleted_by: IotaAddress, + #[serde(deserialize_with = "deserialize_number_from_string")] + pub timestamp: u64, +} + +fn deserialize_permission_set<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + Ok(PermissionSet { + permissions: deserialize_vec_set(deserializer)?, + }) } diff --git a/audit-trail-rs/src/core/types/role_map.rs b/audit-trail-rs/src/core/types/role_map.rs index c0943cb7..f0d71616 100644 --- a/audit-trail-rs/src/core/types/role_map.rs +++ b/audit-trail-rs/src/core/types/role_map.rs @@ -6,6 +6,7 @@ use std::str::FromStr; use iota_interaction::types::TypeTag; use iota_interaction::types::base_types::{IotaAddress, ObjectID}; +use iota_interaction::types::collection_types::LinkedTable; use iota_interaction::types::id::UID; use iota_interaction::types::programmable_transaction_builder::ProgrammableTransactionBuilder as Ptb; use iota_interaction::types::transaction::Argument; @@ -24,8 +25,7 @@ pub struct RoleMap { #[serde(deserialize_with = "deserialize_vec_map")] pub roles: HashMap, pub initial_admin_role_name: String, - #[serde(deserialize_with = "deserialize_vec_set")] - pub issued_capabilities: HashSet, + pub revoked_capabilities: LinkedTable, #[serde(deserialize_with = "deserialize_vec_set")] pub initial_admin_cap_ids: HashSet, pub role_admin_permissions: RoleAdminPermissions, @@ -36,7 +36,7 @@ pub struct RoleMap { pub struct Role { #[serde(deserialize_with = "deserialize_vec_set")] pub permissions: HashSet, - pub data: Option, + pub data: Option, } /// Defines the permissions required to administer roles in this RoleMap. @@ -63,41 +63,41 @@ pub struct CapabilityIssueOptions { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] -pub struct RecordTags { +pub struct RoleTags { #[serde(deserialize_with = "deserialize_vec_set")] - pub allowed_tags: HashSet, + pub tags: HashSet, } -impl RecordTags { - pub fn new(allowed_tags: I) -> Self +impl RoleTags { + pub fn new(tags: I) -> Self where I: IntoIterator, S: Into, { Self { - allowed_tags: allowed_tags.into_iter().map(Into::into).collect(), + tags: tags.into_iter().map(Into::into).collect(), } } pub fn allows(&self, tag: &str) -> bool { - self.allowed_tags.contains(tag) + self.tags.contains(tag) } pub(crate) fn tag(package_id: ObjectID) -> TypeTag { - TypeTag::from_str(&format!("{package_id}::record_tags::RecordTags")).expect("invalid TypeTag for RecordTags") + TypeTag::from_str(&format!("{package_id}::record_tags::RoleTags")).expect("invalid TypeTag for RoleTags") } pub(in crate::core) fn to_ptb(&self, ptb: &mut Ptb, package_id: ObjectID) -> Result { - let mut allowed_tags = self.allowed_tags.iter().cloned().collect::>(); - allowed_tags.sort(); - let allowed_tags_arg = utils::ptb_pure(ptb, "allowed_tags", allowed_tags)?; + let mut tags = self.tags.iter().cloned().collect::>(); + tags.sort(); + let tags_arg = utils::ptb_pure(ptb, "tags", tags)?; Ok(ptb.programmable_move_call( package_id, ident_str!("record_tags").into(), - ident_str!("new_record_tags").into(), + ident_str!("new_role_tags").into(), vec![], - vec![allowed_tags_arg], + vec![tags_arg], )) } } diff --git a/audit-trail-rs/tests/e2e/access.rs b/audit-trail-rs/tests/e2e/access.rs index aae489d6..252f4084 100644 --- a/audit-trail-rs/tests/e2e/access.rs +++ b/audit-trail-rs/tests/e2e/access.rs @@ -3,7 +3,7 @@ use std::collections::HashSet; -use audit_trails::core::types::{CapabilityIssueOptions, Data, Permission, PermissionSet, RecordTags}; +use audit_trails::core::types::{CapabilityIssueOptions, Data, Permission, PermissionSet, RoleTags}; use iota_interaction::types::base_types::IotaAddress; use product_common::core_client::CoreClient; @@ -56,6 +56,13 @@ async fn update_role_permissions_then_issue_capability() -> anyhow::Result<()> { .output; assert_eq!(updated.trail_id, trail_id); assert_eq!(updated.role, role_name.to_string()); + assert_eq!( + updated.permissions.permissions, + HashSet::from([Permission::AddRecord, Permission::DeleteRecord]) + ); + assert_eq!(updated.data, None); + assert_eq!(updated.updated_by, client.sender_address()); + assert!(updated.timestamp > 0); let issued = client .issue_cap(trail_id, role_name, CapabilityIssueOptions::default()) @@ -67,7 +74,7 @@ async fn update_role_permissions_then_issue_capability() -> anyhow::Result<()> { } #[tokio::test] -async fn create_role_rejects_undefined_record_tags() -> anyhow::Result<()> { +async fn create_role_rejects_undefined_role_tags() -> anyhow::Result<()> { let client = get_funded_test_client().await?; let trail_id = client .create_test_trail_with_tags(Data::text("roles-undefined-create"), ["legal"]) @@ -78,7 +85,7 @@ async fn create_role_rejects_undefined_record_tags() -> anyhow::Result<()> { trail_id, "tagged-writer", vec![Permission::AddRecord], - Some(RecordTags::new(["finance"])), + Some(RoleTags::new(["finance"])), ) .await; @@ -91,7 +98,7 @@ async fn create_role_rejects_undefined_record_tags() -> anyhow::Result<()> { } #[tokio::test] -async fn update_role_permissions_rejects_undefined_record_tags() -> anyhow::Result<()> { +async fn update_role_permissions_rejects_undefined_role_tags() -> anyhow::Result<()> { let client = get_funded_test_client().await?; let trail_id = client .create_test_trail_with_tags(Data::text("roles-undefined-update"), ["legal"]) @@ -109,7 +116,7 @@ async fn update_role_permissions_rejects_undefined_record_tags() -> anyhow::Resu PermissionSet { permissions: HashSet::from([Permission::AddRecord]), }, - Some(RecordTags::new(["finance"])), + Some(RoleTags::new(["finance"])), ) .build_and_execute(&client) .await; @@ -140,6 +147,8 @@ async fn delete_role_prevents_new_capability_issuance() -> anyhow::Result<()> { .output; assert_eq!(deleted.trail_id, trail_id); assert_eq!(deleted.role, role_name.to_string()); + assert_eq!(deleted.deleted_by, client.sender_address()); + assert!(deleted.timestamp > 0); let issue_tx = access .for_role(role_name) @@ -196,12 +205,13 @@ async fn revoke_capability_emits_expected_event_data() -> anyhow::Result<()> { .await?; let revoked = access - .revoke_capability(issued.capability_id) + .revoke_capability(issued.capability_id, issued.valid_until) .build_and_execute(&client) .await? .output; assert_eq!(revoked.target_key, trail_id); assert_eq!(revoked.capability_id, issued.capability_id); + assert_eq!(revoked.valid_until, 0); Ok(()) } @@ -229,6 +239,10 @@ async fn destroy_capability_emits_expected_event_data() -> anyhow::Result<()> { assert_eq!(destroyed.target_key, trail_id); assert_eq!(destroyed.capability_id, issued.capability_id); + assert_eq!(destroyed.role, role_name.to_string()); + assert_eq!(destroyed.issued_to, None); + assert_eq!(destroyed.valid_from, None); + assert_eq!(destroyed.valid_until, None); Ok(()) } @@ -250,6 +264,10 @@ async fn destroy_initial_admin_capability_emits_expected_event() -> anyhow::Resu assert_eq!(destroyed.target_key, trail_id); assert_eq!(destroyed.capability_id, admin_cap_id); + assert_eq!(destroyed.role, "Admin".to_string()); + assert_eq!(destroyed.issued_to, None); + assert_eq!(destroyed.valid_from, None); + assert_eq!(destroyed.valid_until, None); Ok(()) } @@ -266,13 +284,14 @@ async fn revoke_initial_admin_capability_emits_expected_event() -> anyhow::Resul let access = client.trail(trail_id).access(); let revoked = access - .revoke_initial_admin_capability(second_admin.capability_id) + .revoke_initial_admin_capability(second_admin.capability_id, second_admin.valid_until) .build_and_execute(&client) .await? .output; assert_eq!(revoked.target_key, trail_id); assert_eq!(revoked.capability_id, second_admin.capability_id); + assert_eq!(revoked.valid_until, 0); Ok(()) } @@ -305,7 +324,10 @@ async fn regular_revoke_rejects_initial_admin_capability() -> anyhow::Result<()> let admin_cap_ref = client.get_cap(client.sender_address(), trail_id).await?; let admin_cap_id = admin_cap_ref.0; - let result = access.revoke_capability(admin_cap_id).build_and_execute(&client).await; + let result = access + .revoke_capability(admin_cap_id, None) + .build_and_execute(&client) + .await; assert!( result.is_err(), @@ -314,3 +336,43 @@ async fn regular_revoke_rejects_initial_admin_capability() -> anyhow::Result<()> Ok(()) } + +#[tokio::test] +async fn cleanup_revoked_capabilities_removes_expired_entries() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client.create_test_trail(Data::text("access-e2e")).await?; + let access = client.trail(trail_id).access(); + let role_name = "cleanup-target"; + + client + .create_role(trail_id, role_name, vec![Permission::AddRecord], None) + .await?; + + let issued = client + .issue_cap( + trail_id, + role_name, + CapabilityIssueOptions { + issued_to: None, + valid_from_ms: None, + valid_until_ms: Some(1), + }, + ) + .await?; + + access + .revoke_capability(issued.capability_id, issued.valid_until) + .build_and_execute(&client) + .await?; + + let trail = client.trail(trail_id); + let before_cleanup = trail.get().await?; + assert_eq!(before_cleanup.roles.revoked_capabilities.size, 1); + + access.cleanup_revoked_capabilities().build_and_execute(&client).await?; + + let after_cleanup = trail.get().await?; + assert_eq!(after_cleanup.roles.revoked_capabilities.size, 0); + + Ok(()) +} diff --git a/audit-trail-rs/tests/e2e/client.rs b/audit-trail-rs/tests/e2e/client.rs index be774a46..390c7c68 100644 --- a/audit-trail-rs/tests/e2e/client.rs +++ b/audit-trail-rs/tests/e2e/client.rs @@ -7,8 +7,8 @@ use std::sync::Arc; use audit_trails::AuditTrailClient; use audit_trails::core::types::{ - Capability, CapabilityIssueOptions, CapabilityIssued, Data, InitialRecord, Permission, PermissionSet, RecordTags, - RoleCreated, + Capability, CapabilityIssueOptions, CapabilityIssued, Data, InitialRecord, Permission, PermissionSet, RoleCreated, + RoleTags, }; use iota_interaction::types::base_types::{IotaAddress, ObjectID, ObjectRef}; use iota_interaction::types::crypto::PublicKey; @@ -107,13 +107,13 @@ impl TestClient { Ok(created.trail_id) } - /// Creates a role on the given trail with the specified permissions. + /// Creates a role on the given trail with the specified permissions and optional role tags. pub(crate) async fn create_role( &self, trail_id: ObjectID, role_name: &str, permissions: impl IntoIterator, - record_tags: Option, + role_tags: Option, ) -> anyhow::Result { let created = self .trail(trail_id) @@ -123,7 +123,7 @@ impl TestClient { PermissionSet { permissions: permissions.into_iter().collect::>(), }, - record_tags, + role_tags, ) .build_and_execute(self) .await? diff --git a/audit-trail-rs/tests/e2e/records.rs b/audit-trail-rs/tests/e2e/records.rs index e73821d3..f17343b5 100644 --- a/audit-trail-rs/tests/e2e/records.rs +++ b/audit-trail-rs/tests/e2e/records.rs @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 use audit_trails::core::types::{ - CapabilityIssueOptions, Data, InitialRecord, LockingConfig, LockingWindow, Permission, RecordTags, TimeLock, + CapabilityIssueOptions, Data, InitialRecord, LockingConfig, LockingWindow, Permission, RoleTags, TimeLock, }; use audit_trails::error::Error; use iota_interaction::types::base_types::ObjectID; @@ -89,7 +89,7 @@ async fn add_and_fetch_tagged_record_roundtrip() -> anyhow::Result<()> { trail_id, "TaggedWriter", [Permission::AddRecord], - Some(RecordTags::new(["finance"])), + Some(RoleTags::new(["finance"])), ) .await?; client @@ -151,7 +151,7 @@ async fn add_tagged_record_requires_trail_defined_tag() -> anyhow::Result<()> { trail_id, "TaggedWriter", [Permission::AddRecord], - Some(RecordTags::new(["finance"])), + Some(RoleTags::new(["finance"])), ) .await?; client diff --git a/audit-trail-rs/tests/e2e/trail.rs b/audit-trail-rs/tests/e2e/trail.rs index 02789797..ab4d9fd6 100644 --- a/audit-trail-rs/tests/e2e/trail.rs +++ b/audit-trail-rs/tests/e2e/trail.rs @@ -2,8 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 use audit_trails::core::types::{ - CapabilityIssueOptions, Data, ImmutableMetadata, InitialRecord, LockingConfig, LockingWindow, Permission, - RecordTags, TimeLock, + CapabilityIssueOptions, Data, ImmutableMetadata, InitialRecord, LockingConfig, LockingWindow, Permission, RoleTags, + TimeLock, }; use iota_interaction::types::base_types::IotaAddress; use product_common::core_client::CoreClient; @@ -450,7 +450,7 @@ async fn remove_record_tag_rejects_in_use_tag() -> anyhow::Result<()> { created.trail_id, "TaggedWriter", vec![Permission::AddRecord], - Some(RecordTags::new(["finance"])), + Some(RoleTags::new(["finance"])), ) .await?; client @@ -487,7 +487,7 @@ async fn remove_record_tag_rejects_role_only_usage() -> anyhow::Result<()> { created.trail_id, "TaggedWriter", vec![Permission::AddRecord], - Some(RecordTags::new(["finance"])), + Some(RoleTags::new(["finance"])), ) .await?; From 16ac6d429905107fd43995748e28f5e34a2f51dd Mon Sep 17 00:00:00 2001 From: Pawel Iwan Date: Thu, 26 Mar 2026 13:36:00 +0100 Subject: [PATCH 099/189] remove validated scope from OperationCap --- notarization-move/Move.history.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/notarization-move/Move.history.json b/notarization-move/Move.history.json index 4de67e54..322ce2ed 100644 --- a/notarization-move/Move.history.json +++ b/notarization-move/Move.history.json @@ -1,18 +1,18 @@ { "aliases": { - "mainnet": "6364aad5", "devnet": "daf90477", + "mainnet": "6364aad5", "testnet": "2304aa97" }, "envs": { - "daf90477": [ - "0x72c8433b88e6bdee0eb02a257fdebd0ec2b6c990043f35b155cb4c5cf727fdca" + "6364aad5": [ + "0x909ce9dcd9a5e97b7b8884fac8e018fad9dece348bf73837379b8694ff684cf3" ], "2304aa97": [ "0x00412bd469b7f980227c6c574090348239852e43aa07818b315854fdd8a2d25f" ], - "6364aad5": [ - "0x909ce9dcd9a5e97b7b8884fac8e018fad9dece348bf73837379b8694ff684cf3" + "daf90477": [ + "0x72c8433b88e6bdee0eb02a257fdebd0ec2b6c990043f35b155cb4c5cf727fdca" ] } } \ No newline at end of file From d019ead8165ae0ecb92019fe66b6cf80298a2773 Mon Sep 17 00:00:00 2001 From: Yasir Date: Thu, 26 Mar 2026 16:46:17 +0300 Subject: [PATCH 100/189] feat: support tf components package overrides --- Cargo.toml | 8 +- audit-trail-move/Move.lock | 20 +-- audit-trail-move/Move.toml | 2 +- audit-trail-move/scripts/publish_package.sh | 55 +++++-- audit-trail-rs/src/client/full_client.rs | 35 +++-- audit-trail-rs/src/client/read_only.rs | 67 ++++----- audit-trail-rs/src/core/access/operations.rs | 18 ++- .../src/core/access/transactions.rs | 8 +- .../src/core/create/transactions.rs | 5 +- audit-trail-rs/src/core/locking/operations.rs | 9 +- audit-trail-rs/src/core/operations.rs | 4 +- audit-trail-rs/src/core/types/event.rs | 2 +- audit-trail-rs/src/core/types/role_map.rs | 20 ++- audit-trail-rs/src/lib.rs | 2 +- audit-trail-rs/src/package.rs | 80 +++++++++- audit-trail-rs/tests/e2e/client.rs | 138 +++++++++++++++++- bindings/wasm/notarization_wasm/Cargo.toml | 6 +- notarization-move/Move.history.json | 12 +- 18 files changed, 364 insertions(+), 127 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index e024870d..4089857e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,10 +18,10 @@ bcs = "0.1" chrono = { version = "0.4", default-features = false } hyper = "1" iota-sdk = { git = "https://github.com/iotaledger/iota.git", package = "iota-sdk", tag = "v1.18.1" } -iota_interaction = { git = "https://github.com/iotaledger/product-core.git", branch = "feat/tf-compoenents-dev", default-features = false, package = "iota_interaction" } -iota_interaction_rust = { git = "https://github.com/iotaledger/product-core.git", branch = "feat/tf-compoenents-dev", default-features = false, package = "iota_interaction_rust" } -iota_interaction_ts = { git = "https://github.com/iotaledger/product-core.git", branch = "feat/tf-compoenents-dev", default-features = false, package = "iota_interaction_ts" } -product_common = { git = "https://github.com/iotaledger/product-core.git", branch = "feat/tf-compoenents-dev", default-features = false, package = "product_common" } +iota_interaction = { git = "https://github.com/iotaledger/product-core.git", branch = "feat/tf-components-package", default-features = false, package = "iota_interaction" } +iota_interaction_rust = { git = "https://github.com/iotaledger/product-core.git", branch = "feat/tf-components-package", default-features = false, package = "iota_interaction_rust" } +iota_interaction_ts = { git = "https://github.com/iotaledger/product-core.git", branch = "feat/tf-components-package", default-features = false, package = "iota_interaction_ts" } +product_common = { git = "https://github.com/iotaledger/product-core.git", branch = "feat/tf-components-package", default-features = false, package = "product_common" } secret-storage = { git = "https://github.com/iotaledger/secret-storage.git", tag = "v0.3.0", default-features = false } serde = { version = "1.0", default-features = false, features = ["alloc", "derive"] } serde-aux = { version = "4.7.0", default-features = false } diff --git a/audit-trail-move/Move.lock b/audit-trail-move/Move.lock index f9f740a2..459d7e44 100644 --- a/audit-trail-move/Move.lock +++ b/audit-trail-move/Move.lock @@ -2,7 +2,7 @@ [move] version = 3 -manifest_digest = "BBDC635C3E5B1F977F4F12056411AADB62CD398CFCA75919B69BE3414CFC8393" +manifest_digest = "BF347B439E6AFB5476981659745E4CCD3E65A22EABA4BFCFA0C2BC9358ECF101" deps_digest = "397E6A9F7A624706DBDFEE056CE88391A15876868FD18A88504DA74EB458D697" dependencies = [ { id = "Iota", name = "Iota" }, @@ -14,7 +14,7 @@ dependencies = [ [[move.package]] id = "Iota" -source = { git = "https://github.com/iotaledger/iota.git", rev = "431ab686e6f1d4abd83b16dd7c671712002ecac8", subdir = "crates/iota-framework/packages/iota-framework" } +source = { git = "https://github.com/iotaledger/iota.git", rev = "b1b37ed9d5ff64cbbfb3aa1ebd9b9431a0337311", subdir = "crates/iota-framework/packages/iota-framework" } dependencies = [ { id = "MoveStdlib", name = "MoveStdlib" }, @@ -22,7 +22,7 @@ dependencies = [ [[move.package]] id = "IotaSystem" -source = { git = "https://github.com/iotaledger/iota.git", rev = "431ab686e6f1d4abd83b16dd7c671712002ecac8", subdir = "crates/iota-framework/packages/iota-system" } +source = { git = "https://github.com/iotaledger/iota.git", rev = "b1b37ed9d5ff64cbbfb3aa1ebd9b9431a0337311", subdir = "crates/iota-framework/packages/iota-system" } dependencies = [ { id = "Iota", name = "Iota" }, @@ -31,11 +31,11 @@ dependencies = [ [[move.package]] id = "MoveStdlib" -source = { git = "https://github.com/iotaledger/iota.git", rev = "431ab686e6f1d4abd83b16dd7c671712002ecac8", subdir = "crates/iota-framework/packages/move-stdlib" } +source = { git = "https://github.com/iotaledger/iota.git", rev = "b1b37ed9d5ff64cbbfb3aa1ebd9b9431a0337311", subdir = "crates/iota-framework/packages/move-stdlib" } [[move.package]] id = "Stardust" -source = { git = "https://github.com/iotaledger/iota.git", rev = "431ab686e6f1d4abd83b16dd7c671712002ecac8", subdir = "crates/iota-framework/packages/stardust" } +source = { git = "https://github.com/iotaledger/iota.git", rev = "b1b37ed9d5ff64cbbfb3aa1ebd9b9431a0337311", subdir = "crates/iota-framework/packages/stardust" } dependencies = [ { id = "Iota", name = "Iota" }, @@ -44,7 +44,7 @@ dependencies = [ [[move.package]] id = "TfComponents" -source = { git = "https://github.com/iotaledger/product-core.git", rev = "feat/tf-compoenents-dev", subdir = "components_move" } +source = { git = "https://github.com/iotaledger/product-core.git", rev = "feat/tf-components-package", subdir = "components_move" } dependencies = [ { id = "Iota", name = "Iota" }, @@ -54,14 +54,14 @@ dependencies = [ ] [move.toolchain-version] -compiler-version = "1.18.1-rc" +compiler-version = "1.19.1" edition = "2024.beta" flavor = "iota" [env] [env.localnet] -chain-id = "78a386e3" -original-published-id = "0xf154e65610afbae141151a26bc624fe57af27917e8e7b1266ab4e8c43659a6ef" -latest-published-id = "0xf154e65610afbae141151a26bc624fe57af27917e8e7b1266ab4e8c43659a6ef" +chain-id = "8b91bd21" +original-published-id = "0x9af052729d135b9bb7e33626af44d73235c8957b5284b515fbe2a7f5ab91c4c4" +latest-published-id = "0x9af052729d135b9bb7e33626af44d73235c8957b5284b515fbe2a7f5ab91c4c4" published-version = "1" diff --git a/audit-trail-move/Move.toml b/audit-trail-move/Move.toml index 3c6e966c..c8b68b71 100644 --- a/audit-trail-move/Move.toml +++ b/audit-trail-move/Move.toml @@ -3,7 +3,7 @@ name = "audit_trail" edition = "2024.beta" [dependencies] -TfComponents = { git = "https://github.com/iotaledger/product-core.git", subdir = "components_move", rev = "feat/tf-compoenents-dev" } +TfComponents = { git = "https://github.com/iotaledger/product-core.git", subdir = "components_move", rev = "feat/tf-components-package" } [addresses] audit_trail = "0x0" diff --git a/audit-trail-move/scripts/publish_package.sh b/audit-trail-move/scripts/publish_package.sh index 00a87d4a..d29a0e3c 100755 --- a/audit-trail-move/scripts/publish_package.sh +++ b/audit-trail-move/scripts/publish_package.sh @@ -3,15 +3,46 @@ # Copyright 2020-2026 IOTA Stiftung # SPDX-License-Identifier: Apache-2.0 -script_dir=$(cd "$(dirname $0)" && pwd) -package_dir=$script_dir/.. - -RESPONSE=$(iota client publish --silence-warnings --json --gas-budget 500000000 $package_dir) -{ # try - PACKAGE_ID=$(echo $RESPONSE | jq --raw-output '.objectChanges[] | select(.type | contains("published")) | .packageId') -} || { # catch - echo $RESPONSE -} -c -export IOTA_AUDIT_TRAIL_PKG_ID=$PACKAGE_ID -echo "${IOTA_AUDIT_TRAIL_PKG_ID}" +set -euo pipefail + +script_dir=$(cd "$(dirname "$0")" && pwd) +package_dir="$script_dir/.." + +active_env=$(iota client active-env --json | jq -r '.') + +publish_args=( + iota client publish + --silence-warnings + --json + --gas-budget 500000000 +) + +if [[ "$active_env" == "localnet" ]]; then + publish_args+=(--with-unpublished-dependencies) +fi + +response=$("${publish_args[@]}" "$package_dir") + +audit_trail_package_id=$( + echo "$response" | jq -r ' + .objectChanges[] + | select(.type == "published") + | .packageId + ' +) + +if [[ -z "$audit_trail_package_id" || "$audit_trail_package_id" == "null" ]]; then + echo "$response" >&2 + echo "failed to extract audit_trail package ID from publish response" >&2 + exit 1 +fi + +export IOTA_AUDIT_TRAIL_PKG_ID="$audit_trail_package_id" +printf 'export IOTA_AUDIT_TRAIL_PKG_ID=%s\n' "$IOTA_AUDIT_TRAIL_PKG_ID" + +if [[ "$active_env" == "localnet" ]]; then + tf_components_package_id="$audit_trail_package_id" + + export IOTA_TF_COMPONENTS_PKG_ID="$tf_components_package_id" + printf 'export IOTA_TF_COMPONENTS_PKG_ID=%s\n' "$IOTA_TF_COMPONENTS_PKG_ID" +fi diff --git a/audit-trail-rs/src/client/full_client.rs b/audit-trail-rs/src/client/full_client.rs index bd57f131..68e7cd8b 100644 --- a/audit-trail-rs/src/client/full_client.rs +++ b/audit-trail-rs/src/client/full_client.rs @@ -22,7 +22,7 @@ use product_common::network_name::NetworkName; use secret_storage::Signer; use serde::de::DeserializeOwned; -use crate::client::read_only::AuditTrailClientReadOnly; +use crate::client::read_only::{AuditTrailClientReadOnly, PackageOverrides}; use crate::core::builder::AuditTrailBuilder; use crate::core::trail::{AuditTrailFull, AuditTrailHandle, AuditTrailReadOnly}; use crate::error::Error; @@ -74,7 +74,8 @@ impl AuditTrailClient { /// Creates a new [AuditTrailClient], with **no** signing capabilities, from the given [IotaClient]. /// /// # Warning - /// Passing a `custom_package_id` is **only** required when connecting to a custom IOTA network. + /// Passing `package_overrides` is **only** required when connecting to a custom IOTA network + /// or when testing against explicitly deployed package pairs. /// /// Relying on a custom Audit Trail package when connected to an official IOTA network is **highly /// discouraged** and is sure to result in compatibility issues when interacting with other official @@ -96,19 +97,21 @@ impl AuditTrailClient { /// ``` pub async fn from_iota_client( iota_client: IotaClient, - custom_package_id: impl Into>, + package_overrides: impl Into>, ) -> Result { - let read_only_client = if let Some(custom_package_id) = custom_package_id.into() { - AuditTrailClientReadOnly::new_with_pkg_id(iota_client, custom_package_id).await - } else { - AuditTrailClientReadOnly::new(iota_client).await - } - .map_err(|e| match e { - Error::InvalidConfig(_) => FromIotaClientErrorKind::MissingPackageId, - Error::RpcError(msg) => FromIotaClientErrorKind::NetworkResolution(msg.into()), - _ => unreachable!("'AuditTrailClientReadOnly::new' has been changed without updating error handling in 'AuditTrailClient::from_iota_client'"), - }) - .map_err(|kind| FromIotaClientError { kind })?; + let read_only_client = if let Some(package_overrides) = package_overrides.into() { + AuditTrailClientReadOnly::new_with_package_overrides(iota_client, package_overrides).await + } else { + AuditTrailClientReadOnly::new(iota_client).await + } + .map_err(|e| match e { + Error::InvalidConfig(_) => FromIotaClientErrorKind::MissingPackageId, + Error::RpcError(msg) => FromIotaClientErrorKind::NetworkResolution(msg.into()), + _ => unreachable!( + "'AuditTrailClientReadOnly::new' has been changed without updating error handling in 'AuditTrailClient::from_iota_client'" + ), + }) + .map_err(|kind| FromIotaClientError { kind })?; Ok(Self { read_client: read_only_client, @@ -176,6 +179,10 @@ impl CoreClientReadOnly for AuditTrailClient { self.read_client.package_id() } + fn tf_components_package_id(&self) -> Option { + self.read_client.tf_components_package_id() + } + fn network_name(&self) -> &NetworkName { self.read_client.network() } diff --git a/audit-trail-rs/src/client/read_only.rs b/audit-trail-rs/src/client/read_only.rs index 8cc4e5a7..220c8c24 100644 --- a/audit-trail-rs/src/client/read_only.rs +++ b/audit-trail-rs/src/client/read_only.rs @@ -14,7 +14,6 @@ use iota_interaction::types::transaction::{ProgrammableTransaction, TransactionK use iota_interaction_ts::bindings::WasmIotaClient; use product_common::core_client::CoreClientReadOnly; use product_common::network_name::NetworkName; -use product_common::package_registry::Env; use serde::de::DeserializeOwned; use super::network_id; @@ -23,6 +22,13 @@ use crate::error::Error; use crate::iota_interaction_adapter::IotaClientAdapter; use crate::package; +/// Optional package ID overrides used when constructing an audit trail client. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct PackageOverrides { + pub audit_trail_package_id: Option, + pub tf_components_package_id: Option, +} + /// A read-only client for interacting with audit trail module objects on a specific network. #[derive(Clone)] pub struct AuditTrailClientReadOnly { @@ -30,6 +36,8 @@ pub struct AuditTrailClientReadOnly { iota_client: IotaClientAdapter, /// The [`ObjectID`] of the deployed audit trail package (smart contract). audit_trail_pkg_id: ObjectID, + /// The [`ObjectID`] of the deployed TfComponents package used by audit trails. + pub(crate) tf_components_pkg_id: ObjectID, /// The name of the network this client is connected to (e.g., "mainnet", "testnet"). network: NetworkName, /// Raw chain identifier returned by the IOTA node. @@ -78,60 +86,39 @@ impl AuditTrailClientReadOnly { ) -> Result { let client = IotaClientAdapter::new(iota_client); let network = network_id(&client).await?; - Self::new_internal(client, network).await + Self::new_internal(client, network, PackageOverrides::default()).await } - async fn new_internal(iota_client: IotaClientAdapter, network: NetworkName) -> Result { + async fn new_internal( + iota_client: IotaClientAdapter, + network: NetworkName, + package_overrides: PackageOverrides, + ) -> Result { let chain_id = network.as_ref().to_string(); - let (network, audit_trail_pkg_id) = { - let package_registry = package::audit_trail_package_registry().await; - let package_id = package_registry - .package_id(&network) - .ok_or_else(|| { - Error::InvalidConfig(format!( - "no information for a published `audit_trail` package on network {network}; try to use `AuditTrailClientReadOnly::new_with_pkg_id`" - )) - })?; - let network = match chain_id.as_str() { - product_common::package_registry::MAINNET_CHAIN_ID => { - NetworkName::try_from("iota").expect("valid network name") - } - _ => package_registry - .chain_alias(&chain_id) - .and_then(|alias| NetworkName::try_from(alias).ok()) - .unwrap_or(network), - }; - - (network, package_id) - }; + let (network, package_ids) = package::resolve_package_ids(&network, &package_overrides).await?; Ok(Self { iota_client, - audit_trail_pkg_id, + audit_trail_pkg_id: package_ids.audit_trail_package_id, + tf_components_pkg_id: package_ids.tf_components_package_id, network, chain_id, }) } - /// Creates a new [`AuditTrailClientReadOnly`] with a specific audit trail package ID. + /// Creates a new [`AuditTrailClientReadOnly`] with explicit package overrides. /// - /// This function allows overriding the package ID lookup from the - /// registry, which is useful for connecting to networks where the package - /// ID is known but not yet registered, or for testing with custom deployments. - pub async fn new_with_pkg_id( + /// This function allows overriding the package ID lookup from the registry, + /// which is useful for local testing or custom deployments where the package + /// IDs are known ahead of time. + pub async fn new_with_package_overrides( #[cfg(target_arch = "wasm32")] iota_client: WasmIotaClient, #[cfg(not(target_arch = "wasm32"))] iota_client: IotaClient, - package_id: ObjectID, + package_overrides: PackageOverrides, ) -> Result { let client = IotaClientAdapter::new(iota_client); let network = network_id(&client).await?; - - { - let mut registry = package::audit_trail_package_registry_mut().await; - registry.insert_env_history(Env::new(network.as_ref()), vec![package_id]); - } - - Self::new_internal(client, network).await + Self::new_internal(client, network, package_overrides).await } } @@ -148,6 +135,10 @@ impl CoreClientReadOnly for AuditTrailClientReadOnly { fn client_adapter(&self) -> &IotaClientAdapter { &self.iota_client } + + fn tf_components_package_id(&self) -> Option { + Some(self.tf_components_pkg_id) + } } #[async_trait::async_trait] diff --git a/audit-trail-rs/src/core/access/operations.rs b/audit-trail-rs/src/core/access/operations.rs index 08b7a4d6..8029814a 100644 --- a/audit-trail-rs/src/core/access/operations.rs +++ b/audit-trail-rs/src/core/access/operations.rs @@ -6,7 +6,7 @@ use iota_interaction::types::transaction::{ObjectArg, ProgrammableTransaction}; use iota_interaction::{OptionalSync, ident_str}; use product_common::core_client::CoreClientReadOnly; -use crate::core::types::{CapabilityIssueOptions, Permission, PermissionSet, RecordTags}; +use crate::core::types::{Capability, CapabilityIssueOptions, Permission, PermissionSet, RecordTags}; use crate::core::{operations, utils}; use crate::error::Error; @@ -171,6 +171,11 @@ impl AccessOps { where C: CoreClientReadOnly + OptionalSync, { + let capability: Capability = client + .get_object_by_id(capability_id) + .await + .map_err(|e| Error::UnexpectedApiResponse(format!("failed to fetch capability {capability_id}; {e}")))?; + operations::build_trail_transaction( client, trail_id, @@ -179,9 +184,10 @@ impl AccessOps { "revoke_capability", |ptb, _| { let cap = utils::ptb_pure(ptb, "capability_id", capability_id)?; + let valid_until = utils::ptb_pure(ptb, "capability_valid_until", capability.valid_until)?; let clock = utils::get_clock_ref(ptb); - Ok(vec![cap, clock]) + Ok(vec![cap, valid_until, clock]) }, ) .await @@ -244,6 +250,11 @@ impl AccessOps { where C: CoreClientReadOnly + OptionalSync, { + let capability: Capability = client + .get_object_by_id(capability_id) + .await + .map_err(|e| Error::UnexpectedApiResponse(format!("failed to fetch capability {capability_id}; {e}")))?; + operations::build_trail_transaction( client, trail_id, @@ -252,9 +263,10 @@ impl AccessOps { "revoke_initial_admin_capability", |ptb, _| { let cap = utils::ptb_pure(ptb, "capability_id", capability_id)?; + let valid_until = utils::ptb_pure(ptb, "capability_valid_until", capability.valid_until)?; let clock = utils::get_clock_ref(ptb); - Ok(vec![cap, clock]) + Ok(vec![cap, valid_until, clock]) }, ) .await diff --git a/audit-trail-rs/src/core/access/transactions.rs b/audit-trail-rs/src/core/access/transactions.rs index cbf5cddc..d1149f3c 100644 --- a/audit-trail-rs/src/core/access/transactions.rs +++ b/audit-trail-rs/src/core/access/transactions.rs @@ -13,7 +13,7 @@ use tokio::sync::OnceCell; use super::operations::AccessOps; use crate::core::types::{ CapabilityDestroyed, CapabilityIssueOptions, CapabilityIssued, CapabilityRevoked, Event, PermissionSet, RecordTags, - RoleCreated, RoleRemoved, RoleUpdated, + RoleCreated, RoleDeleted, RoleUpdated, }; use crate::error::Error; @@ -215,7 +215,7 @@ impl DeleteRole { #[cfg_attr(feature = "send-sync", async_trait)] impl Transaction for DeleteRole { type Error = Error; - type Output = RoleRemoved; + type Output = RoleDeleted; async fn build_programmable_transaction(&self, client: &C) -> Result where @@ -236,8 +236,8 @@ impl Transaction for DeleteRole { let event = events .data .iter() - .find_map(|data| serde_json::from_value::>(data.parsed_json.clone()).ok()) - .ok_or_else(|| Error::UnexpectedApiResponse("RoleRemoved event not found".to_string()))?; + .find_map(|data| serde_json::from_value::>(data.parsed_json.clone()).ok()) + .ok_or_else(|| Error::UnexpectedApiResponse("RoleDeleted event not found".to_string()))?; Ok(event.data) } diff --git a/audit-trail-rs/src/core/create/transactions.rs b/audit-trail-rs/src/core/create/transactions.rs index a7d987c1..ef405bcb 100644 --- a/audit-trail-rs/src/core/create/transactions.rs +++ b/audit-trail-rs/src/core/create/transactions.rs @@ -15,7 +15,6 @@ use crate::core::builder::AuditTrailBuilder; use crate::core::operations; use crate::core::types::{AuditTrailCreated, Event, OnChainAuditTrail}; use crate::error::Error; -use crate::package; /// Output of a create trail transaction. #[derive(Debug, Clone)] @@ -69,11 +68,11 @@ impl CreateTrail { .to_string(), ) })?; - let tf_components_package_id = package::tf_components_package_id(client.network_name().as_ref())?; + let tf_package_id = client.tf_components_package_id().expect("package ID is present"); CreateOps::create_trail(CreateTrailArgs { audit_trail_package_id: client.package_id(), - tf_components_package_id, + tf_components_package_id: tf_package_id, admin, initial_record, locking_config, diff --git a/audit-trail-rs/src/core/locking/operations.rs b/audit-trail-rs/src/core/locking/operations.rs index f959c043..a7c27e3f 100644 --- a/audit-trail-rs/src/core/locking/operations.rs +++ b/audit-trail-rs/src/core/locking/operations.rs @@ -9,7 +9,6 @@ use product_common::core_client::CoreClientReadOnly; use crate::core::types::{LockingConfig, LockingWindow, Permission, TimeLock}; use crate::core::{operations, utils}; use crate::error::Error; -use crate::package; pub(super) struct LockingOps; @@ -23,7 +22,7 @@ impl LockingOps { where C: CoreClientReadOnly + OptionalSync, { - let tf_components_package_id = package::tf_components_package_id(client.network_name().as_ref())?; + let tf_components_package_id = client.tf_components_package_id().expect("package ID is present"); operations::build_trail_transaction( client, @@ -75,8 +74,7 @@ impl LockingOps { where C: CoreClientReadOnly + OptionalSync, { - let tf_components_package_id = package::tf_components_package_id(client.network_name().as_ref())?; - + let tf_components_package_id = client.tf_components_package_id().expect("package ID is present"); operations::build_trail_transaction( client, trail_id, @@ -102,8 +100,7 @@ impl LockingOps { where C: CoreClientReadOnly + OptionalSync, { - let tf_components_package_id = package::tf_components_package_id(client.network_name().as_ref())?; - + let tf_components_package_id = client.tf_components_package_id().expect("package ID is present"); operations::build_trail_transaction( client, trail_id, diff --git a/audit-trail-rs/src/core/operations.rs b/audit-trail-rs/src/core/operations.rs index ac2a3c99..3f78e425 100644 --- a/audit-trail-rs/src/core/operations.rs +++ b/audit-trail-rs/src/core/operations.rs @@ -18,7 +18,6 @@ use product_common::core_client::CoreClientReadOnly; use crate::core::types::{Capability, OnChainAuditTrail, Permission}; use crate::core::utils; use crate::error::Error; -use crate::package; pub async fn get_audit_trail(trail_id: ObjectID, client: &C) -> Result where @@ -56,7 +55,6 @@ where C: CoreClientReadOnly + OptionalSync, { let trail = get_audit_trail(trail_id, client).await?; - let cap_ref = find_capable_cap(client, owner, trail_id, &trail, permission).await?; build_trail_transaction_with_cap_ref(client, trail_id, cap_ref, method, additional_args).await } @@ -103,7 +101,7 @@ where C: CoreClientReadOnly + OptionalSync, P: Fn(&Capability) -> bool + Send, { - let tf_components_package_id = package::tf_components_package_id(client.network_name().as_ref())?; + let tf_components_package_id = client.tf_components_package_id().expect("package ID is present"); let capability_struct_tag: StructTag = Capability::type_tag(tf_components_package_id) .to_string() .parse() diff --git a/audit-trail-rs/src/core/types/event.rs b/audit-trail-rs/src/core/types/event.rs index 5a73ca65..353954a8 100644 --- a/audit-trail-rs/src/core/types/event.rs +++ b/audit-trail-rs/src/core/types/event.rs @@ -86,7 +86,7 @@ pub struct RoleUpdated { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct RoleRemoved { +pub struct RoleDeleted { #[serde(rename = "target_key")] pub trail_id: ObjectID, pub role: String, diff --git a/audit-trail-rs/src/core/types/role_map.rs b/audit-trail-rs/src/core/types/role_map.rs index 9d310e33..1bb009de 100644 --- a/audit-trail-rs/src/core/types/role_map.rs +++ b/audit-trail-rs/src/core/types/role_map.rs @@ -4,12 +4,13 @@ use std::collections::{HashMap, HashSet}; use std::str::FromStr; -use iota_interaction::ident_str; use iota_interaction::types::TypeTag; use iota_interaction::types::base_types::{IotaAddress, ObjectID}; +use iota_interaction::types::collection_types::LinkedTable; use iota_interaction::types::id::UID; use iota_interaction::types::programmable_transaction_builder::ProgrammableTransactionBuilder as Ptb; use iota_interaction::types::transaction::Argument; +use iota_interaction::{MoveType, ident_str}; use serde::{Deserialize, Serialize}; use super::permission::Permission; @@ -22,8 +23,7 @@ pub struct RoleMap { #[serde(deserialize_with = "deserialize_vec_map")] pub roles: HashMap, pub initial_admin_role_name: String, - #[serde(deserialize_with = "deserialize_vec_set")] - pub issued_capabilities: HashSet, + pub revoked_capabilities: LinkedTable, #[serde(deserialize_with = "deserialize_vec_set")] pub initial_admin_cap_ids: HashSet, pub role_admin_permissions: RoleAdminPermissions, @@ -60,6 +60,10 @@ pub struct CapabilityIssueOptions { pub valid_until_ms: Option, } +/// Allowlisted record tags stored as role data on the Move side. +/// +/// The Rust name stays `RecordTags` for API continuity, but it maps to the +/// Move `record_tags::RoleTags` type. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] pub struct RecordTags { #[serde(deserialize_with = "deserialize_vec_set")] @@ -82,7 +86,7 @@ impl RecordTags { } pub(crate) fn tag(package_id: ObjectID) -> TypeTag { - TypeTag::from_str(&format!("{package_id}::record_tags::RecordTags")).expect("invalid TypeTag for RecordTags") + TypeTag::from_str(&format!("{package_id}::record_tags::RoleTags")).expect("invalid TypeTag for RoleTags") } pub(in crate::core) fn to_ptb(&self, ptb: &mut Ptb, package_id: ObjectID) -> Result { @@ -93,7 +97,7 @@ impl RecordTags { Ok(ptb.programmable_move_call( package_id, ident_str!("record_tags").into(), - ident_str!("new_record_tags").into(), + ident_str!("new_role_tags").into(), vec![], vec![allowed_tags_arg], )) @@ -120,3 +124,9 @@ impl Capability { self.target_key == trail_id && valid_roles.contains(&self.role) } } + +impl MoveType for Capability { + fn move_type(package: ObjectID) -> TypeTag { + Self::type_tag(package) + } +} diff --git a/audit-trail-rs/src/lib.rs b/audit-trail-rs/src/lib.rs index 29a01d11..2c8f8644 100644 --- a/audit-trail-rs/src/lib.rs +++ b/audit-trail-rs/src/lib.rs @@ -8,7 +8,7 @@ pub(crate) mod iota_interaction_adapter; pub(crate) mod package; pub use client::full_client::AuditTrailClient; -pub use client::read_only::AuditTrailClientReadOnly; +pub use client::read_only::{AuditTrailClientReadOnly, PackageOverrides}; /// HTTP utilities to implement the trait [HttpClient](product_common::http_client::HttpClient). #[cfg(feature = "gas-station")] pub use product_common::http_client; diff --git a/audit-trail-rs/src/package.rs b/audit-trail-rs/src/package.rs index bd9de4aa..4818f56b 100644 --- a/audit-trail-rs/src/package.rs +++ b/audit-trail-rs/src/package.rs @@ -11,10 +11,14 @@ use std::sync::LazyLock; use iota_interaction::types::base_types::ObjectID; -use product_common::package_registry::PackageRegistry; +use product_common::network_name::NetworkName; +use product_common::package_registry::{Env, PackageRegistry}; use product_common::tf_components_registry; use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard, TryLockError}; +use crate::client::PackageOverrides; +use crate::error::Error; + type PackageRegistryLock = RwLockReadGuard<'static, PackageRegistry>; type PackageRegistryLockMut = RwLockWriteGuard<'static, PackageRegistry>; @@ -30,6 +34,10 @@ static AUDIT_TRAIL_PACKAGE_REGISTRY: LazyLock> = LazyLoc ) }); +/// Runtime overrides for TfComponents package information. +static TF_COMPONENTS_OVERRIDE_REGISTRY: LazyLock> = + LazyLock::new(|| RwLock::new(PackageRegistry::default())); + /// Returns a read lock to the package registry. pub(crate) async fn audit_trail_package_registry() -> PackageRegistryLock { AUDIT_TRAIL_PACKAGE_REGISTRY.read().await @@ -60,10 +68,70 @@ pub(crate) fn blocking_audit_trail_registry_mut() -> PackageRegistryLockMut { AUDIT_TRAIL_PACKAGE_REGISTRY.blocking_write() } -pub(crate) fn tf_components_package_id(network: &str) -> Result { - tf_components_registry::tf_components_package_id(network).ok_or_else(|| { - crate::error::Error::InvalidConfig(format!( - "no information for a published `TfComponents` package on network {network}" +pub(crate) async fn tf_components_override_registry_mut() -> PackageRegistryLockMut { + TF_COMPONENTS_OVERRIDE_REGISTRY.write().await +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct ResolvedPackageIds { + pub audit_trail_package_id: ObjectID, + pub tf_components_package_id: ObjectID, +} + +pub(crate) async fn resolve_package_ids( + network: &NetworkName, + package_overrides: &PackageOverrides, +) -> Result<(NetworkName, ResolvedPackageIds), Error> { + let chain_id = network.as_ref().to_string(); + let package_registry = audit_trail_package_registry().await; + let audit_trail_package_id = package_overrides + .audit_trail_package_id + .or_else(|| package_registry.package_id(network)) + .ok_or_else(|| { + Error::InvalidConfig(format!( + "no information for a published `audit_trail` package on network {network}; try to use `AuditTrailClientReadOnly::new_with_package_overrides`" + )) + })?; + let resolved_network = match chain_id.as_str() { + product_common::package_registry::MAINNET_CHAIN_ID => { + NetworkName::try_from("iota").expect("valid network name") + } + _ => package_registry + .chain_alias(&chain_id) + .and_then(|alias| NetworkName::try_from(alias).ok()) + .unwrap_or_else(|| network.clone()), + }; + + drop(package_registry); + + let env = Env::new_with_alias(chain_id.clone(), resolved_network.as_ref()); + if let Some(audit_trail_package_id) = package_overrides.audit_trail_package_id { + audit_trail_package_registry_mut() + .await + .insert_env_history(env.clone(), vec![audit_trail_package_id]); + } + if let Some(tf_components_package_id) = package_overrides.tf_components_package_id { + tf_components_override_registry_mut() + .await + .insert_env_history(env, vec![tf_components_package_id]); + } + + let tf_components_package_id = resolve_tf_components_package_id(resolved_network.as_ref()).await.ok_or_else(|| { + Error::InvalidConfig(format!( + "no information for a published `TfComponents` package on network {network}; try to use `AuditTrailClientReadOnly::new_with_package_overrides`" )) - }) + })?; + + Ok(( + resolved_network, + ResolvedPackageIds { + audit_trail_package_id, + tf_components_package_id, + }, + )) +} + +pub(crate) async fn resolve_tf_components_package_id(network: &str) -> Option { + let override_package_id = TF_COMPONENTS_OVERRIDE_REGISTRY.read().await.package_id(network); + override_package_id.or_else(|| tf_components_registry::tf_components_package_id(network)) } diff --git a/audit-trail-rs/tests/e2e/client.rs b/audit-trail-rs/tests/e2e/client.rs index be774a46..66a8e77e 100644 --- a/audit-trail-rs/tests/e2e/client.rs +++ b/audit-trail-rs/tests/e2e/client.rs @@ -3,23 +3,27 @@ use std::collections::HashSet; use std::ops::Deref; +use std::str::FromStr; use std::sync::Arc; -use audit_trails::AuditTrailClient; +use anyhow::{Context, anyhow}; use audit_trails::core::types::{ Capability, CapabilityIssueOptions, CapabilityIssued, Data, InitialRecord, Permission, PermissionSet, RecordTags, RoleCreated, }; +use audit_trails::{AuditTrailClient, PackageOverrides}; use iota_interaction::types::base_types::{IotaAddress, ObjectID, ObjectRef}; use iota_interaction::types::crypto::PublicKey; -use iota_interaction::{IOTA_LOCAL_NETWORK_URL, IotaClientBuilder}; +use iota_interaction::{IOTA_LOCAL_NETWORK_URL, IotaClient, IotaClientBuilder}; use iota_interaction_rust::IotaClientAdapter; use product_common::core_client::{CoreClient, CoreClientReadOnly}; use product_common::network_name::NetworkName; -use product_common::test_utils::{InMemSigner, init_product_package, request_funds}; +use product_common::test_utils::{InMemSigner, request_funds}; +use tokio::fs; +use tokio::process::Command; use tokio::sync::OnceCell; -static PACKAGE_ID: OnceCell = OnceCell::const_new(); +static PACKAGE_IDS: OnceCell = OnceCell::const_new(); /// Script file for publishing the package. pub const PUBLISH_SCRIPT_FILE: &str = concat!( @@ -27,10 +31,118 @@ pub const PUBLISH_SCRIPT_FILE: &str = concat!( "/../audit-trail-move/scripts/publish_package.sh" ); +const CACHED_PKG_FILE: &str = "/tmp/audit_trail_pkg_ids.txt"; + +#[derive(Clone, Copy)] +struct PublishedPackageIds { + audit_trail_package_id: ObjectID, + tf_components_package_id: Option, +} + pub async fn get_funded_test_client() -> anyhow::Result { TestClient::new().await } +async fn load_cached_package_ids(chain_id: &str) -> anyhow::Result { + let cache = fs::read_to_string(CACHED_PKG_FILE).await?; + let mut parts = cache.trim().split(';'); + let audit_trail_package_id = parts + .next() + .ok_or_else(|| anyhow!("missing audit_trail package ID in cache"))?; + let tf_components_package_id = parts.next().unwrap_or_default(); + let cached_chain_id = parts.next().ok_or_else(|| anyhow!("missing chain ID in cache"))?; + + if cached_chain_id != chain_id { + anyhow::bail!("cached package IDs belong to a different chain"); + } + + Ok(PublishedPackageIds { + audit_trail_package_id: ObjectID::from_str(audit_trail_package_id) + .context("failed to parse cached audit_trail package ID")?, + tf_components_package_id: if tf_components_package_id.is_empty() { + None + } else { + Some( + ObjectID::from_str(tf_components_package_id) + .context("failed to parse cached TfComponents package ID")?, + ) + }, + }) +} + +async fn publish_package_ids(iota_client: &IotaClient) -> anyhow::Result { + let chain_id = iota_client + .read_api() + .get_chain_identifier() + .await + .map_err(|e| anyhow!(e.to_string()))?; + + if let Ok(ids) = load_cached_package_ids(&chain_id).await { + return Ok(ids); + } + + let output = Command::new("bash") + .arg(PUBLISH_SCRIPT_FILE) + .output() + .await + .context("failed to execute publish_package.sh")?; + + let stdout = std::str::from_utf8(&output.stdout).context("publish script stdout is not valid utf-8")?; + + if !output.status.success() { + let stderr = std::str::from_utf8(&output.stderr).context("publish script stderr is not valid utf-8")?; + anyhow::bail!("failed to publish move package: \n\n{stdout}\n\n{stderr}"); + } + + let mut audit_trail_package_id = None; + let mut tf_components_package_id = None; + + for line in stdout.lines() { + let Some(exported) = line.strip_prefix("export ") else { + continue; + }; + let Some((key, value)) = exported.split_once('=') else { + continue; + }; + + match key { + "IOTA_AUDIT_TRAIL_PKG_ID" => { + let package_id = + ObjectID::from_str(value).context("failed to parse published audit_trail package ID")?; + audit_trail_package_id = Some(package_id); + } + "IOTA_TF_COMPONENTS_PKG_ID" => { + let package_id = + ObjectID::from_str(value).context("failed to parse published TfComponents package ID")?; + tf_components_package_id = Some(package_id); + } + _ => {} + } + } + + let ids = PublishedPackageIds { + audit_trail_package_id: audit_trail_package_id + .ok_or_else(|| anyhow!("publish script did not expose IOTA_AUDIT_TRAIL_PKG_ID"))?, + tf_components_package_id, + }; + + fs::write( + CACHED_PKG_FILE, + format!( + "{};{};{}", + ids.audit_trail_package_id, + ids.tf_components_package_id + .map(|package_id| package_id.to_string()) + .unwrap_or_default(), + chain_id + ), + ) + .await + .context("failed to write cached package IDs")?; + + Ok(ids) +} + #[derive(Clone)] pub struct TestClient { client: Arc>, @@ -47,8 +159,8 @@ impl TestClient { pub async fn new() -> anyhow::Result { let api_endpoint = std::env::var("API_ENDPOINT").unwrap_or_else(|_| IOTA_LOCAL_NETWORK_URL.to_string()); let iota_client = IotaClientBuilder::default().build(&api_endpoint).await?; - let package_id = PACKAGE_ID - .get_or_try_init(|| init_product_package(&iota_client, None, Some(PUBLISH_SCRIPT_FILE))) + let package_ids = PACKAGE_IDS + .get_or_try_init(|| publish_package_ids(&iota_client)) .await .copied()?; @@ -57,7 +169,15 @@ impl TestClient { let signer_address = signer.get_address().await?; request_funds(&signer_address).await?; - let client = AuditTrailClient::from_iota_client(iota_client.clone(), Some(package_id)).await?; + let client = AuditTrailClient::from_iota_client( + iota_client.clone(), + Some(PackageOverrides { + audit_trail_package_id: Some(package_ids.audit_trail_package_id), + tf_components_package_id: package_ids.tf_components_package_id, + ..PackageOverrides::default() + }), + ) + .await?; let client = client.with_signer(signer).await?; Ok(TestClient { @@ -155,6 +275,10 @@ impl CoreClientReadOnly for TestClient { self.client.package_id() } + fn tf_components_package_id(&self) -> Option { + self.client.tf_components_package_id() + } + fn network_name(&self) -> &NetworkName { self.client.network_name() } diff --git a/bindings/wasm/notarization_wasm/Cargo.toml b/bindings/wasm/notarization_wasm/Cargo.toml index fc88bc29..4277fc2e 100644 --- a/bindings/wasm/notarization_wasm/Cargo.toml +++ b/bindings/wasm/notarization_wasm/Cargo.toml @@ -21,8 +21,8 @@ async-trait = { version = "0.1", default-features = false } bcs = "0.1.6" console_error_panic_hook = { version = "0.1" } fastcrypto = { git = "https://github.com/MystenLabs/fastcrypto", rev = "69d496c71fb37e3d22fe85e5bbfd4256d61422b9", package = "fastcrypto" } -iota_interaction = { git = "https://github.com/iotaledger/product-core.git", branch = "feat/tf-compoenents-dev", package = "iota_interaction", default-features = false } -iota_interaction_ts = { git = "https://github.com/iotaledger/product-core.git", branch = "feat/tf-compoenents-dev", package = "iota_interaction_ts" } +iota_interaction = { git = "https://github.com/iotaledger/product-core.git", branch = "feat/tf-components-package", package = "iota_interaction", default-features = false } +iota_interaction_ts = { git = "https://github.com/iotaledger/product-core.git", branch = "feat/tf-components-package", package = "iota_interaction_ts" } js-sys = { version = "=0.3.85" } prefix-hex = { version = "0.7", default-features = false } serde = { version = "1.0", features = ["derive"] } @@ -37,7 +37,7 @@ wasm-bindgen-futures = { version = "0.4", default-features = false } [dependencies.product_common] git = "https://github.com/iotaledger/product-core.git" -branch = "feat/tf-compoenents-dev" +branch = "feat/tf-components-package" package = "product_common" features = [ "core-client", diff --git a/notarization-move/Move.history.json b/notarization-move/Move.history.json index 4de67e54..e2ab094c 100644 --- a/notarization-move/Move.history.json +++ b/notarization-move/Move.history.json @@ -1,18 +1,18 @@ { "aliases": { - "mainnet": "6364aad5", "devnet": "daf90477", - "testnet": "2304aa97" + "testnet": "2304aa97", + "mainnet": "6364aad5" }, "envs": { - "daf90477": [ - "0x72c8433b88e6bdee0eb02a257fdebd0ec2b6c990043f35b155cb4c5cf727fdca" + "6364aad5": [ + "0x909ce9dcd9a5e97b7b8884fac8e018fad9dece348bf73837379b8694ff684cf3" ], "2304aa97": [ "0x00412bd469b7f980227c6c574090348239852e43aa07818b315854fdd8a2d25f" ], - "6364aad5": [ - "0x909ce9dcd9a5e97b7b8884fac8e018fad9dece348bf73837379b8694ff684cf3" + "daf90477": [ + "0x72c8433b88e6bdee0eb02a257fdebd0ec2b6c990043f35b155cb4c5cf727fdca" ] } } \ No newline at end of file From b31c8dde856d7caf360f0119a0894ddb2c3fbb53 Mon Sep 17 00:00:00 2001 From: Yasir Date: Fri, 27 Mar 2026 11:29:52 +0300 Subject: [PATCH 101/189] chore: clippy and fmt fixes --- audit-trail-rs/src/core/access/mod.rs | 6 ++++-- audit-trail-rs/src/core/access/operations.rs | 2 +- audit-trail-rs/src/core/types/event.rs | 4 +++- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/audit-trail-rs/src/core/access/mod.rs b/audit-trail-rs/src/core/access/mod.rs index 398c9c90..25a155b3 100644 --- a/audit-trail-rs/src/core/access/mod.rs +++ b/audit-trail-rs/src/core/access/mod.rs @@ -36,7 +36,8 @@ impl<'a, C> TrailAccess<'a, C> { /// Revokes an issued capability. /// - /// Pass the capability's `valid_until` value when it is known so the denylist entry matches the on-chain cleanup model. + /// Pass the capability's `valid_until` value when it is known so the denylist entry matches the on-chain cleanup + /// model. pub fn revoke_capability( &self, capability_id: ObjectID, @@ -79,7 +80,8 @@ impl<'a, C> TrailAccess<'a, C> { /// Revokes an initial admin capability by ID. /// - /// Pass the capability's `valid_until` value when it is known so the denylist entry matches the on-chain cleanup model. + /// Pass the capability's `valid_until` value when it is known so the denylist entry matches the on-chain cleanup + /// model. pub fn revoke_initial_admin_capability( &self, capability_id: ObjectID, diff --git a/audit-trail-rs/src/core/access/operations.rs b/audit-trail-rs/src/core/access/operations.rs index 80705ab5..a9759125 100644 --- a/audit-trail-rs/src/core/access/operations.rs +++ b/audit-trail-rs/src/core/access/operations.rs @@ -299,7 +299,7 @@ where let undefined_tags = role_tags .tags .iter() - .filter(|tag| !trail.tags.contains_key(*tag)) + .filter(|tag| !trail.tags.contains_key(tag)) .cloned() .collect::>(); diff --git a/audit-trail-rs/src/core/types/event.rs b/audit-trail-rs/src/core/types/event.rs index 068e5f0a..9e65e13d 100644 --- a/audit-trail-rs/src/core/types/event.rs +++ b/audit-trail-rs/src/core/types/event.rs @@ -167,7 +167,9 @@ impl EventPermission { "Migrate" => Ok(Permission::Migrate), "AddRecordTags" => Ok(Permission::AddRecordTags), "DeleteRecordTags" => Ok(Permission::DeleteRecordTags), - other => Err(E::custom(format!("unknown permission variant `{other}` in event payload"))), + other => Err(E::custom(format!( + "unknown permission variant `{other}` in event payload" + ))), } } } From 7a64101e91735fae7dfdca5f2c126eaf4a628fdf Mon Sep 17 00:00:00 2001 From: Yasir Date: Fri, 27 Mar 2026 13:42:04 +0300 Subject: [PATCH 102/189] refactor: decode role events from bcs --- .../src/core/access/transactions.rs | 14 +- audit-trail-rs/src/core/types/event.rs | 138 ++++++++++-------- audit-trail-rs/tests/e2e/client.rs | 1 - 3 files changed, 88 insertions(+), 65 deletions(-) diff --git a/audit-trail-rs/src/core/access/transactions.rs b/audit-trail-rs/src/core/access/transactions.rs index 84866a5a..b26d605d 100644 --- a/audit-trail-rs/src/core/access/transactions.rs +++ b/audit-trail-rs/src/core/access/transactions.rs @@ -13,7 +13,7 @@ use tokio::sync::OnceCell; use super::operations::AccessOps; use crate::core::types::{ CapabilityDestroyed, CapabilityIssueOptions, CapabilityIssued, CapabilityRevoked, Event, PermissionSet, - RoleCreated, RoleDeleted, RoleTags, RoleUpdated, + RawRoleCreated, RawRoleDeleted, RawRoleUpdated, RoleCreated, RoleDeleted, RoleTags, RoleUpdated, }; use crate::error::Error; @@ -88,10 +88,10 @@ impl Transaction for CreateRole { let event = events .data .iter() - .find_map(|data| serde_json::from_value::>(data.parsed_json.clone()).ok()) + .find_map(|data| bcs::from_bytes::(data.bcs.bytes()).ok().map(Into::into)) .ok_or_else(|| Error::UnexpectedApiResponse("RoleCreated event not found".to_string()))?; - Ok(event.data) + Ok(event) } async fn apply(mut self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result @@ -171,10 +171,10 @@ impl Transaction for UpdateRole { let event = events .data .iter() - .find_map(|data| serde_json::from_value::>(data.parsed_json.clone()).ok()) + .find_map(|data| bcs::from_bytes::(data.bcs.bytes()).ok().map(Into::into)) .ok_or_else(|| Error::UnexpectedApiResponse("RoleUpdated event not found".to_string()))?; - Ok(event.data) + Ok(event) } async fn apply(mut self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result @@ -236,10 +236,10 @@ impl Transaction for DeleteRole { let event = events .data .iter() - .find_map(|data| serde_json::from_value::>(data.parsed_json.clone()).ok()) + .find_map(|data| bcs::from_bytes::(data.bcs.bytes()).ok().map(Into::into)) .ok_or_else(|| Error::UnexpectedApiResponse("RoleDeleted event not found".to_string()))?; - Ok(event.data) + Ok(event) } async fn apply(mut self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result diff --git a/audit-trail-rs/src/core/types/event.rs b/audit-trail-rs/src/core/types/event.rs index 9e65e13d..ef91b814 100644 --- a/audit-trail-rs/src/core/types/event.rs +++ b/audit-trail-rs/src/core/types/event.rs @@ -1,7 +1,10 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +use std::collections::HashSet; + use iota_interaction::types::base_types::{IotaAddress, ObjectID}; +use iota_sdk::types::collection_types::VecSet; use serde::{Deserialize, Serialize}; use serde_aux::field_attributes::{deserialize_number_from_string, deserialize_option_number_from_string}; @@ -81,95 +84,116 @@ pub struct CapabilityRevoked { pub valid_until: u64, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct RoleCreated { - #[serde(rename = "target_key")] pub trail_id: ObjectID, pub role: String, - #[serde(deserialize_with = "deserialize_permission_set")] pub permissions: PermissionSet, pub data: Option, pub created_by: IotaAddress, - #[serde(deserialize_with = "deserialize_number_from_string")] pub timestamp: u64, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct RoleUpdated { - #[serde(rename = "target_key")] pub trail_id: ObjectID, pub role: String, - #[serde(rename = "new_permissions", deserialize_with = "deserialize_permission_set")] pub permissions: PermissionSet, - #[serde(rename = "new_data")] pub data: Option, pub updated_by: IotaAddress, - #[serde(deserialize_with = "deserialize_number_from_string")] pub timestamp: u64, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct RoleDeleted { - #[serde(rename = "target_key")] pub trail_id: ObjectID, pub role: String, pub deleted_by: IotaAddress, - #[serde(deserialize_with = "deserialize_number_from_string")] pub timestamp: u64, } -fn deserialize_permission_set<'de, D>(deserializer: D) -> Result -where - D: serde::Deserializer<'de>, -{ - let vec_set = EventVecSet::::deserialize(deserializer)?; - let permissions = vec_set - .contents - .into_iter() - .map(|permission| permission.into_permission()) - .collect::>()?; +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub(crate) struct RawRoleCreated { + target_key: ObjectID, + role: String, + permissions: VecSet, + data: Option, + created_by: IotaAddress, + timestamp: u64, +} - Ok(PermissionSet { permissions }) +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub(crate) struct RawRoleUpdated { + target_key: ObjectID, + role: String, + new_permissions: VecSet, + new_data: Option, + updated_by: IotaAddress, + timestamp: u64, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] -struct EventVecSet { - contents: Vec, +pub(crate) struct RawRoleDeleted { + target_key: ObjectID, + role: String, + deleted_by: IotaAddress, + timestamp: u64, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] -struct EventPermission { - variant: String, -} - -impl EventPermission { - fn into_permission(self) -> Result - where - E: serde::de::Error, - { - match self.variant.as_str() { - "DeleteAuditTrail" => Ok(Permission::DeleteAuditTrail), - "DeleteAllRecords" => Ok(Permission::DeleteAllRecords), - "AddRecord" => Ok(Permission::AddRecord), - "DeleteRecord" => Ok(Permission::DeleteRecord), - "CorrectRecord" => Ok(Permission::CorrectRecord), - "UpdateLockingConfig" => Ok(Permission::UpdateLockingConfig), - "UpdateLockingConfigForDeleteRecord" => Ok(Permission::UpdateLockingConfigForDeleteRecord), - "UpdateLockingConfigForDeleteTrail" => Ok(Permission::UpdateLockingConfigForDeleteTrail), - "UpdateLockingConfigForWrite" => Ok(Permission::UpdateLockingConfigForWrite), - "AddRoles" => Ok(Permission::AddRoles), - "UpdateRoles" => Ok(Permission::UpdateRoles), - "DeleteRoles" => Ok(Permission::DeleteRoles), - "AddCapabilities" => Ok(Permission::AddCapabilities), - "RevokeCapabilities" => Ok(Permission::RevokeCapabilities), - "UpdateMetadata" => Ok(Permission::UpdateMetadata), - "DeleteMetadata" => Ok(Permission::DeleteMetadata), - "Migrate" => Ok(Permission::Migrate), - "AddRecordTags" => Ok(Permission::AddRecordTags), - "DeleteRecordTags" => Ok(Permission::DeleteRecordTags), - other => Err(E::custom(format!( - "unknown permission variant `{other}` in event payload" - ))), +pub(crate) struct RawRoleTags { + tags: VecSet, +} + +impl From> for PermissionSet { + fn from(value: VecSet) -> Self { + Self { + permissions: value.contents.into_iter().collect::>(), + } + } +} + +impl From for RoleTags { + fn from(value: RawRoleTags) -> Self { + Self { + tags: value.tags.contents.into_iter().collect::>(), + } + } +} + +impl From for RoleCreated { + fn from(value: RawRoleCreated) -> Self { + Self { + trail_id: value.target_key, + role: value.role, + permissions: value.permissions.into(), + data: value.data.map(Into::into), + created_by: value.created_by, + timestamp: value.timestamp, + } + } +} + +impl From for RoleUpdated { + fn from(value: RawRoleUpdated) -> Self { + Self { + trail_id: value.target_key, + role: value.role, + permissions: value.new_permissions.into(), + data: value.new_data.map(Into::into), + updated_by: value.updated_by, + timestamp: value.timestamp, + } + } +} + +impl From for RoleDeleted { + fn from(value: RawRoleDeleted) -> Self { + Self { + trail_id: value.target_key, + role: value.role, + deleted_by: value.deleted_by, + timestamp: value.timestamp, } } } diff --git a/audit-trail-rs/tests/e2e/client.rs b/audit-trail-rs/tests/e2e/client.rs index e21d9c46..75dbd1f7 100644 --- a/audit-trail-rs/tests/e2e/client.rs +++ b/audit-trail-rs/tests/e2e/client.rs @@ -174,7 +174,6 @@ impl TestClient { Some(PackageOverrides { audit_trail_package_id: Some(package_ids.audit_trail_package_id), tf_components_package_id: package_ids.tf_components_package_id, - ..PackageOverrides::default() }), ) .await?; From 1a678ecc17f4a638163ca793d1e651ae5cfea2a6 Mon Sep 17 00:00:00 2001 From: Yasir Date: Fri, 27 Mar 2026 15:11:39 +0300 Subject: [PATCH 103/189] chore: update branch rev --- Cargo.toml | 25 ++++++++++++++++------ audit-trail-move/Move.toml | 2 +- bindings/wasm/notarization_wasm/Cargo.toml | 14 +++++++----- 3 files changed, 28 insertions(+), 13 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 4089857e..d95568d7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,18 +18,29 @@ bcs = "0.1" chrono = { version = "0.4", default-features = false } hyper = "1" iota-sdk = { git = "https://github.com/iotaledger/iota.git", package = "iota-sdk", tag = "v1.18.1" } -iota_interaction = { git = "https://github.com/iotaledger/product-core.git", branch = "feat/tf-components-package", default-features = false, package = "iota_interaction" } -iota_interaction_rust = { git = "https://github.com/iotaledger/product-core.git", branch = "feat/tf-components-package", default-features = false, package = "iota_interaction_rust" } -iota_interaction_ts = { git = "https://github.com/iotaledger/product-core.git", branch = "feat/tf-components-package", default-features = false, package = "iota_interaction_ts" } -product_common = { git = "https://github.com/iotaledger/product-core.git", branch = "feat/tf-components-package", default-features = false, package = "product_common" } +iota_interaction = { git = "https://github.com/iotaledger/product-core.git", branch = "feat/tf-compoenents-dev", default-features = false, package = "iota_interaction" } +iota_interaction_rust = { git = "https://github.com/iotaledger/product-core.git", branch = "feat/tf-compoenents-dev", default-features = false, package = "iota_interaction_rust" } +iota_interaction_ts = { git = "https://github.com/iotaledger/product-core.git", branch = "feat/tf-compoenents-dev", default-features = false, package = "iota_interaction_ts" } +product_common = { git = "https://github.com/iotaledger/product-core.git", branch = "feat/tf-compoenents-dev", default-features = false, package = "product_common" } secret-storage = { git = "https://github.com/iotaledger/secret-storage.git", tag = "v0.3.0", default-features = false } -serde = { version = "1.0", default-features = false, features = ["alloc", "derive"] } +serde = { version = "1.0", default-features = false, features = [ + "alloc", + "derive", +] } serde-aux = { version = "4.7.0", default-features = false } serde_json = { version = "1.0", default-features = false } sha2 = { version = "0.10", default-features = false } -strum = { version = "0.27", default-features = false, features = ["std", "derive"] } +strum = { version = "0.27", default-features = false, features = [ + "std", + "derive", +] } thiserror = { version = "2.0", default-features = false } -tokio = { version = "1.46.1", default-features = false, features = ["macros", "sync", "rt", "process"] } +tokio = { version = "1.46.1", default-features = false, features = [ + "macros", + "sync", + "rt", + "process", +] } [profile.release.package.iota_interaction_ts] opt-level = 's' diff --git a/audit-trail-move/Move.toml b/audit-trail-move/Move.toml index c8b68b71..3c6e966c 100644 --- a/audit-trail-move/Move.toml +++ b/audit-trail-move/Move.toml @@ -3,7 +3,7 @@ name = "audit_trail" edition = "2024.beta" [dependencies] -TfComponents = { git = "https://github.com/iotaledger/product-core.git", subdir = "components_move", rev = "feat/tf-components-package" } +TfComponents = { git = "https://github.com/iotaledger/product-core.git", subdir = "components_move", rev = "feat/tf-compoenents-dev" } [addresses] audit_trail = "0x0" diff --git a/bindings/wasm/notarization_wasm/Cargo.toml b/bindings/wasm/notarization_wasm/Cargo.toml index 4277fc2e..20bc09f4 100644 --- a/bindings/wasm/notarization_wasm/Cargo.toml +++ b/bindings/wasm/notarization_wasm/Cargo.toml @@ -21,8 +21,8 @@ async-trait = { version = "0.1", default-features = false } bcs = "0.1.6" console_error_panic_hook = { version = "0.1" } fastcrypto = { git = "https://github.com/MystenLabs/fastcrypto", rev = "69d496c71fb37e3d22fe85e5bbfd4256d61422b9", package = "fastcrypto" } -iota_interaction = { git = "https://github.com/iotaledger/product-core.git", branch = "feat/tf-components-package", package = "iota_interaction", default-features = false } -iota_interaction_ts = { git = "https://github.com/iotaledger/product-core.git", branch = "feat/tf-components-package", package = "iota_interaction_ts" } +iota_interaction = { git = "https://github.com/iotaledger/product-core.git", branch = "feat/tf-compoenents-dev", package = "iota_interaction", default-features = false } +iota_interaction_ts = { git = "https://github.com/iotaledger/product-core.git", branch = "feat/tf-compoenents-dev", package = "iota_interaction_ts" } js-sys = { version = "=0.3.85" } prefix-hex = { version = "0.7", default-features = false } serde = { version = "1.0", features = ["derive"] } @@ -37,7 +37,7 @@ wasm-bindgen-futures = { version = "0.4", default-features = false } [dependencies.product_common] git = "https://github.com/iotaledger/product-core.git" -branch = "feat/tf-components-package" +branch = "feat/tf-compoenents-dev" package = "product_common" features = [ "core-client", @@ -54,7 +54,9 @@ default-features = false features = ["gas-station", "default-http-client", "irl"] [target.'cfg(all(target_arch = "wasm32", not(target_os = "wasi")))'.dependencies] -getrandom = { version = "0.3", default-features = false, features = ["wasm_js"] } +getrandom = { version = "0.3", default-features = false, features = [ + "wasm_js", +] } [profile.release] opt-level = 's' @@ -72,4 +74,6 @@ empty_docs = "allow" [lints.rust] # required for current wasm_bindgen version -unexpected_cfgs = { level = "warn", check-cfg = ['cfg(wasm_bindgen_unstable_test_coverage)'] } +unexpected_cfgs = { level = "warn", check-cfg = [ + 'cfg(wasm_bindgen_unstable_test_coverage)', +] } From 898ccf17f20833303cda8094e5f44909d3f942ab Mon Sep 17 00:00:00 2001 From: Yasir Date: Fri, 27 Mar 2026 16:21:28 +0300 Subject: [PATCH 104/189] chore: fmt --- Cargo.toml | 17 +++-------------- bindings/wasm/notarization_wasm/Cargo.toml | 8 ++------ 2 files changed, 5 insertions(+), 20 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index d95568d7..e024870d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,24 +23,13 @@ iota_interaction_rust = { git = "https://github.com/iotaledger/product-core.git" iota_interaction_ts = { git = "https://github.com/iotaledger/product-core.git", branch = "feat/tf-compoenents-dev", default-features = false, package = "iota_interaction_ts" } product_common = { git = "https://github.com/iotaledger/product-core.git", branch = "feat/tf-compoenents-dev", default-features = false, package = "product_common" } secret-storage = { git = "https://github.com/iotaledger/secret-storage.git", tag = "v0.3.0", default-features = false } -serde = { version = "1.0", default-features = false, features = [ - "alloc", - "derive", -] } +serde = { version = "1.0", default-features = false, features = ["alloc", "derive"] } serde-aux = { version = "4.7.0", default-features = false } serde_json = { version = "1.0", default-features = false } sha2 = { version = "0.10", default-features = false } -strum = { version = "0.27", default-features = false, features = [ - "std", - "derive", -] } +strum = { version = "0.27", default-features = false, features = ["std", "derive"] } thiserror = { version = "2.0", default-features = false } -tokio = { version = "1.46.1", default-features = false, features = [ - "macros", - "sync", - "rt", - "process", -] } +tokio = { version = "1.46.1", default-features = false, features = ["macros", "sync", "rt", "process"] } [profile.release.package.iota_interaction_ts] opt-level = 's' diff --git a/bindings/wasm/notarization_wasm/Cargo.toml b/bindings/wasm/notarization_wasm/Cargo.toml index 20bc09f4..fc88bc29 100644 --- a/bindings/wasm/notarization_wasm/Cargo.toml +++ b/bindings/wasm/notarization_wasm/Cargo.toml @@ -54,9 +54,7 @@ default-features = false features = ["gas-station", "default-http-client", "irl"] [target.'cfg(all(target_arch = "wasm32", not(target_os = "wasi")))'.dependencies] -getrandom = { version = "0.3", default-features = false, features = [ - "wasm_js", -] } +getrandom = { version = "0.3", default-features = false, features = ["wasm_js"] } [profile.release] opt-level = 's' @@ -74,6 +72,4 @@ empty_docs = "allow" [lints.rust] # required for current wasm_bindgen version -unexpected_cfgs = { level = "warn", check-cfg = [ - 'cfg(wasm_bindgen_unstable_test_coverage)', -] } +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(wasm_bindgen_unstable_test_coverage)'] } From 5356c2400d17698fcd347bb6e7de354961d7530d Mon Sep 17 00:00:00 2001 From: Yasir Date: Fri, 27 Mar 2026 18:08:20 +0300 Subject: [PATCH 105/189] Align audit trails WASM and TfComponents package IDs --- audit-trail-move/Move.lock | 10 +- audit-trail-rs/Cargo.toml | 1 + audit-trail-rs/src/client/full_client.rs | 7 +- audit-trail-rs/src/client/read_only.rs | 13 +- audit-trail-rs/src/core/builder.rs | 3 +- .../src/core/create/transactions.rs | 4 +- audit-trail-rs/src/core/locking/operations.rs | 12 +- audit-trail-rs/src/core/operations.rs | 4 +- audit-trail-rs/src/core/types/event.rs | 2 +- audit-trail-rs/src/core/types/role_map.rs | 4 +- audit-trail-rs/tests/e2e/client.rs | 2 +- bindings/wasm/audit_trails_wasm/Cargo.toml | 6 +- .../wasm/audit_trails_wasm/src/builder.rs | 6 +- bindings/wasm/audit_trails_wasm/src/client.rs | 36 ++++- .../audit_trails_wasm/src/client_read_only.rs | 73 ++++++++++- bindings/wasm/audit_trails_wasm/src/trail.rs | 40 +++++- .../src/trail_handle/access.rs | 42 ++++-- .../audit_trails_wasm/src/trail_handle/mod.rs | 9 +- bindings/wasm/audit_trails_wasm/src/types.rs | 123 +++++++++++++----- 19 files changed, 310 insertions(+), 87 deletions(-) diff --git a/audit-trail-move/Move.lock b/audit-trail-move/Move.lock index 459d7e44..428052f6 100644 --- a/audit-trail-move/Move.lock +++ b/audit-trail-move/Move.lock @@ -2,7 +2,7 @@ [move] version = 3 -manifest_digest = "BF347B439E6AFB5476981659745E4CCD3E65A22EABA4BFCFA0C2BC9358ECF101" +manifest_digest = "A98478D66EC9631ABE28DCBEBAD8D608F813CA5A3D0856E6282E31F4FE7B20FF" deps_digest = "397E6A9F7A624706DBDFEE056CE88391A15876868FD18A88504DA74EB458D697" dependencies = [ { id = "Iota", name = "Iota" }, @@ -44,7 +44,7 @@ dependencies = [ [[move.package]] id = "TfComponents" -source = { git = "https://github.com/iotaledger/product-core.git", rev = "feat/tf-components-package", subdir = "components_move" } +source = { local = "../../product-core/components_move" } dependencies = [ { id = "Iota", name = "Iota" }, @@ -61,7 +61,7 @@ flavor = "iota" [env] [env.localnet] -chain-id = "8b91bd21" -original-published-id = "0x9af052729d135b9bb7e33626af44d73235c8957b5284b515fbe2a7f5ab91c4c4" -latest-published-id = "0x9af052729d135b9bb7e33626af44d73235c8957b5284b515fbe2a7f5ab91c4c4" +chain-id = "ae919a4a" +original-published-id = "0xc948ffc200b507b869f22e38109491abd0ff9c4dc7bf3223cfb04c8da8d96d5e" +latest-published-id = "0xc948ffc200b507b869f22e38109491abd0ff9c4dc7bf3223cfb04c8da8d96d5e" published-version = "1" diff --git a/audit-trail-rs/Cargo.toml b/audit-trail-rs/Cargo.toml index 2b3393c4..475e652b 100644 --- a/audit-trail-rs/Cargo.toml +++ b/audit-trail-rs/Cargo.toml @@ -32,6 +32,7 @@ tokio = { workspace = true } [target.'cfg(target_arch = "wasm32")'.dependencies] iota_interaction_ts.workspace = true +product_common = { workspace = true, default-features = false, features = ["bindings"] } tokio = { version = "1.46.1", default-features = false, features = ["sync"] } [dev-dependencies] diff --git a/audit-trail-rs/src/client/full_client.rs b/audit-trail-rs/src/client/full_client.rs index f3c76f43..cf0dd34b 100644 --- a/audit-trail-rs/src/client/full_client.rs +++ b/audit-trail-rs/src/client/full_client.rs @@ -160,6 +160,11 @@ impl AuditTrailClient { AuditTrailHandle::new(self, trail_id) } + /// Returns the TfComponents package ID used by this client. + pub fn tf_components_package_id(&self) -> ObjectID { + self.read_client.tf_components_package_id() + } + /// Creates a builder for an audit trail. pub fn create_trail(&self) -> AuditTrailBuilder { AuditTrailBuilder { @@ -197,7 +202,7 @@ impl CoreClientReadOnly for AuditTrailClient { } fn tf_components_package_id(&self) -> Option { - self.read_client.tf_components_package_id() + Some(self.read_client.tf_components_package_id()) } fn network_name(&self) -> &NetworkName { diff --git a/audit-trail-rs/src/client/read_only.rs b/audit-trail-rs/src/client/read_only.rs index 250fc77b..46c8ab0f 100644 --- a/audit-trail-rs/src/client/read_only.rs +++ b/audit-trail-rs/src/client/read_only.rs @@ -67,6 +67,11 @@ impl AuditTrailClientReadOnly { self.audit_trail_pkg_id } + /// Returns the TfComponents package ID used by this client. + pub fn tf_components_package_id(&self) -> ObjectID { + self.tf_components_pkg_id + } + /// Returns a reference to the underlying IOTA client adapter. pub const fn iota_client(&self) -> &IotaClientAdapter { &self.iota_client @@ -129,6 +134,10 @@ impl CoreClientReadOnly for AuditTrailClientReadOnly { self.audit_trail_pkg_id } + fn tf_components_package_id(&self) -> Option { + Some(self.tf_components_pkg_id) + } + fn network_name(&self) -> &NetworkName { &self.network } @@ -136,10 +145,6 @@ impl CoreClientReadOnly for AuditTrailClientReadOnly { fn client_adapter(&self) -> &IotaClientAdapter { &self.iota_client } - - fn tf_components_package_id(&self) -> Option { - Some(self.tf_components_pkg_id) - } } #[cfg_attr(not(feature = "send-sync"), async_trait::async_trait(?Send))] diff --git a/audit-trail-rs/src/core/builder.rs b/audit-trail-rs/src/core/builder.rs index 542c6f2b..f143c176 100644 --- a/audit-trail-rs/src/core/builder.rs +++ b/audit-trail-rs/src/core/builder.rs @@ -3,9 +3,10 @@ //! Audit trail builder for creation transactions. +use std::collections::HashSet; + use iota_interaction::types::base_types::IotaAddress; use product_common::transaction::transaction_builder::TransactionBuilder; -use std::collections::HashSet; use super::types::{Data, ImmutableMetadata, InitialRecord, LockingConfig}; use crate::core::create::CreateTrail; diff --git a/audit-trail-rs/src/core/create/transactions.rs b/audit-trail-rs/src/core/create/transactions.rs index ef405bcb..7427004b 100644 --- a/audit-trail-rs/src/core/create/transactions.rs +++ b/audit-trail-rs/src/core/create/transactions.rs @@ -68,7 +68,9 @@ impl CreateTrail { .to_string(), ) })?; - let tf_package_id = client.tf_components_package_id().expect("package ID is present"); + let tf_package_id = client + .tf_components_package_id() + .expect("TfComponents package ID should be present for audit trail clients"); CreateOps::create_trail(CreateTrailArgs { audit_trail_package_id: client.package_id(), diff --git a/audit-trail-rs/src/core/locking/operations.rs b/audit-trail-rs/src/core/locking/operations.rs index a7c27e3f..8ef5b66f 100644 --- a/audit-trail-rs/src/core/locking/operations.rs +++ b/audit-trail-rs/src/core/locking/operations.rs @@ -22,7 +22,9 @@ impl LockingOps { where C: CoreClientReadOnly + OptionalSync, { - let tf_components_package_id = client.tf_components_package_id().expect("package ID is present"); + let tf_components_package_id = client + .tf_components_package_id() + .expect("TfComponents package ID should be present for audit trail clients"); operations::build_trail_transaction( client, @@ -74,7 +76,9 @@ impl LockingOps { where C: CoreClientReadOnly + OptionalSync, { - let tf_components_package_id = client.tf_components_package_id().expect("package ID is present"); + let tf_components_package_id = client + .tf_components_package_id() + .expect("TfComponents package ID should be present for audit trail clients"); operations::build_trail_transaction( client, trail_id, @@ -100,7 +104,9 @@ impl LockingOps { where C: CoreClientReadOnly + OptionalSync, { - let tf_components_package_id = client.tf_components_package_id().expect("package ID is present"); + let tf_components_package_id = client + .tf_components_package_id() + .expect("TfComponents package ID should be present for audit trail clients"); operations::build_trail_transaction( client, trail_id, diff --git a/audit-trail-rs/src/core/operations.rs b/audit-trail-rs/src/core/operations.rs index 3f78e425..19eeca55 100644 --- a/audit-trail-rs/src/core/operations.rs +++ b/audit-trail-rs/src/core/operations.rs @@ -101,7 +101,9 @@ where C: CoreClientReadOnly + OptionalSync, P: Fn(&Capability) -> bool + Send, { - let tf_components_package_id = client.tf_components_package_id().expect("package ID is present"); + let tf_components_package_id = client + .tf_components_package_id() + .expect("TfComponents package ID should be present for audit trail clients"); let capability_struct_tag: StructTag = Capability::type_tag(tf_components_package_id) .to_string() .parse() diff --git a/audit-trail-rs/src/core/types/event.rs b/audit-trail-rs/src/core/types/event.rs index ef91b814..d52fa828 100644 --- a/audit-trail-rs/src/core/types/event.rs +++ b/audit-trail-rs/src/core/types/event.rs @@ -4,7 +4,7 @@ use std::collections::HashSet; use iota_interaction::types::base_types::{IotaAddress, ObjectID}; -use iota_sdk::types::collection_types::VecSet; +use iota_interaction::types::collection_types::VecSet; use serde::{Deserialize, Serialize}; use serde_aux::field_attributes::{deserialize_number_from_string, deserialize_option_number_from_string}; diff --git a/audit-trail-rs/src/core/types/role_map.rs b/audit-trail-rs/src/core/types/role_map.rs index 32ea9957..264d6ea7 100644 --- a/audit-trail-rs/src/core/types/role_map.rs +++ b/audit-trail-rs/src/core/types/role_map.rs @@ -4,13 +4,13 @@ use std::collections::{HashMap, HashSet}; use std::str::FromStr; +use iota_interaction::types::TypeTag; use iota_interaction::types::base_types::{IotaAddress, ObjectID}; use iota_interaction::types::collection_types::LinkedTable; use iota_interaction::types::id::UID; use iota_interaction::types::programmable_transaction_builder::ProgrammableTransactionBuilder as Ptb; use iota_interaction::types::transaction::Argument; -use iota_interaction::types::TypeTag; -use iota_interaction::{ident_str, MoveType}; +use iota_interaction::{MoveType, ident_str}; use serde::{Deserialize, Serialize}; use super::permission::Permission; diff --git a/audit-trail-rs/tests/e2e/client.rs b/audit-trail-rs/tests/e2e/client.rs index 75dbd1f7..d86e4950 100644 --- a/audit-trail-rs/tests/e2e/client.rs +++ b/audit-trail-rs/tests/e2e/client.rs @@ -275,7 +275,7 @@ impl CoreClientReadOnly for TestClient { } fn tf_components_package_id(&self) -> Option { - self.client.tf_components_package_id() + Some(self.client.tf_components_package_id()) } fn network_name(&self) -> &NetworkName { diff --git a/bindings/wasm/audit_trails_wasm/Cargo.toml b/bindings/wasm/audit_trails_wasm/Cargo.toml index 2e05c0d8..92be7df8 100644 --- a/bindings/wasm/audit_trails_wasm/Cargo.toml +++ b/bindings/wasm/audit_trails_wasm/Cargo.toml @@ -20,10 +20,10 @@ anyhow = "1.0.95" audit_trails = { path = "../../../audit-trail-rs", default-features = false, features = ["gas-station", "default-http-client"] } bcs = "0.1.6" console_error_panic_hook = { version = "0.1" } -iota_interaction = { git = "https://github.com/iotaledger/product-core.git", rev = "23225ad2688f04ab45bedda7fda4d7cbc92ccbdf", package = "iota_interaction", default-features = false } -iota_interaction_ts = { git = "https://github.com/iotaledger/product-core.git", rev = "23225ad2688f04ab45bedda7fda4d7cbc92ccbdf", package = "iota_interaction_ts" } +iota_interaction = { git = "https://github.com/iotaledger/product-core.git", branch = "feat/tf-compoenents-dev", package = "iota_interaction", default-features = false } +iota_interaction_ts = { git = "https://github.com/iotaledger/product-core.git", branch = "feat/tf-compoenents-dev", package = "iota_interaction_ts" } js-sys = { version = "0.3.61" } -product_common = { git = "https://github.com/iotaledger/product-core.git", rev = "23225ad2688f04ab45bedda7fda4d7cbc92ccbdf", package = "product_common", features = ["core-client", "transaction", "bindings", "binding-utils", "gas-station", "default-http-client"] } +product_common = { git = "https://github.com/iotaledger/product-core.git", branch = "feat/tf-compoenents-dev", package = "product_common", features = ["core-client", "transaction", "bindings", "binding-utils", "gas-station", "default-http-client"] } serde = { version = "1.0", features = ["derive"] } serde-wasm-bindgen = "0.6.5" tokio = { version = "1.49.0", default-features = false, features = ["sync"] } diff --git a/bindings/wasm/audit_trails_wasm/src/builder.rs b/bindings/wasm/audit_trails_wasm/src/builder.rs index 5106fc46..0350f09a 100644 --- a/bindings/wasm/audit_trails_wasm/src/builder.rs +++ b/bindings/wasm/audit_trails_wasm/src/builder.rs @@ -9,7 +9,7 @@ use product_common::bindings::WasmIotaAddress; use wasm_bindgen::prelude::*; use crate::trail::WasmCreateTrail; -use crate::types::{WasmLockingConfig, WasmRecordTags}; +use crate::types::WasmLockingConfig; #[wasm_bindgen(js_name = AuditTrailBuilder, inspectable)] pub struct WasmAuditTrailBuilder(pub(crate) AuditTrailBuilder); @@ -47,8 +47,8 @@ impl WasmAuditTrailBuilder { } #[wasm_bindgen(js_name = withRecordTags)] - pub fn with_record_tags(self, record_tags: WasmRecordTags) -> Self { - Self(self.0.with_record_tags(record_tags.allowed_tags)) + pub fn with_record_tags(self, tags: Vec) -> Self { + Self(self.0.with_record_tags(tags)) } #[wasm_bindgen(js_name = withAdmin)] diff --git a/bindings/wasm/audit_trails_wasm/src/client.rs b/bindings/wasm/audit_trails_wasm/src/client.rs index 18f97bdf..4d5001ed 100644 --- a/bindings/wasm/audit_trails_wasm/src/client.rs +++ b/bindings/wasm/audit_trails_wasm/src/client.rs @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 use anyhow::anyhow; -use audit_trails::{AuditTrailClient, AuditTrailClientReadOnly}; +use audit_trails::{AuditTrailClient, AuditTrailClientReadOnly, PackageOverrides}; use iota_interaction_ts::bindings::{WasmIotaClient, WasmPublicKey, WasmTransactionSigner}; use iota_interaction_ts::wasm_error::{wasm_error, Result, WasmResult}; use product_common::bindings::utils::parse_wasm_object_id; @@ -11,7 +11,7 @@ use product_common::core_client::{CoreClient, CoreClientReadOnly}; use wasm_bindgen::prelude::*; use crate::builder::WasmAuditTrailBuilder; -use crate::client_read_only::WasmAuditTrailClientReadOnly; +use crate::client_read_only::{WasmAuditTrailClientReadOnly, WasmPackageOverrides}; use crate::trail_handle::WasmAuditTrailHandle; #[derive(Clone)] @@ -37,7 +37,32 @@ impl WasmAuditTrailClient { ) -> Result { let read_only = if let Some(package_id) = package_id { let package_id = parse_wasm_object_id(&package_id)?; - AuditTrailClientReadOnly::new_with_pkg_id(iota_client, package_id) + AuditTrailClientReadOnly::new_with_package_overrides( + iota_client, + PackageOverrides { + audit_trail_package_id: Some(package_id), + tf_components_package_id: None, + }, + ) + .await + .wasm_result()? + } else { + AuditTrailClientReadOnly::new(iota_client).await.wasm_result()? + }; + + let client = AuditTrailClient::new(read_only, signer).await.wasm_result()?; + Ok(Self(client)) + } + + #[wasm_bindgen(js_name = createFromIotaClientWithPackageOverrides)] + pub async fn create_from_iota_client_with_package_overrides( + iota_client: WasmIotaClient, + signer: WasmTransactionSigner, + package_overrides: Option, + ) -> Result { + let read_only = if let Some(package_overrides) = package_overrides { + let package_overrides = PackageOverrides::try_from(package_overrides)?; + AuditTrailClientReadOnly::new_with_package_overrides(iota_client, package_overrides) .await .wasm_result()? } else { @@ -73,6 +98,11 @@ impl WasmAuditTrailClient { self.0.package_id().to_string() } + #[wasm_bindgen(js_name = tfComponentsPackageId)] + pub fn tf_components_package_id(&self) -> String { + self.0.tf_components_package_id().to_string() + } + #[wasm_bindgen(js_name = packageHistory)] pub fn package_history(&self) -> Vec { self.0 diff --git a/bindings/wasm/audit_trails_wasm/src/client_read_only.rs b/bindings/wasm/audit_trails_wasm/src/client_read_only.rs index c94d1925..7ede06be 100644 --- a/bindings/wasm/audit_trails_wasm/src/client_read_only.rs +++ b/bindings/wasm/audit_trails_wasm/src/client_read_only.rs @@ -1,7 +1,7 @@ // Copyright 2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use audit_trails::AuditTrailClientReadOnly; +use audit_trails::{AuditTrailClientReadOnly, PackageOverrides}; use iota_interaction_ts::bindings::WasmIotaClient; use iota_interaction_ts::wasm_error::{Result, WasmResult}; use product_common::bindings::utils::parse_wasm_object_id; @@ -11,6 +11,48 @@ use wasm_bindgen::prelude::*; use crate::trail_handle::WasmAuditTrailHandle; +#[derive(Clone)] +#[wasm_bindgen(js_name = PackageOverrides, getter_with_clone, inspectable)] +pub struct WasmPackageOverrides { + #[wasm_bindgen(js_name = auditTrailPackageId)] + pub audit_trail_package_id: Option, + #[wasm_bindgen(js_name = tfComponentsPackageId)] + pub tf_components_package_id: Option, +} + +#[wasm_bindgen(js_class = PackageOverrides)] +impl WasmPackageOverrides { + #[wasm_bindgen(constructor)] + pub fn new( + audit_trail_package_id: Option, + tf_components_package_id: Option, + ) -> WasmPackageOverrides { + Self { + audit_trail_package_id, + tf_components_package_id, + } + } +} + +impl TryFrom for PackageOverrides { + type Error = JsValue; + + fn try_from(value: WasmPackageOverrides) -> std::result::Result { + Ok(Self { + audit_trail_package_id: value + .audit_trail_package_id + .as_ref() + .map(parse_wasm_object_id) + .transpose()?, + tf_components_package_id: value + .tf_components_package_id + .as_ref() + .map(parse_wasm_object_id) + .transpose()?, + }) + } +} + #[derive(Clone)] #[wasm_bindgen(js_name = AuditTrailClientReadOnly)] pub struct WasmAuditTrailClientReadOnly(pub(crate) AuditTrailClientReadOnly); @@ -23,15 +65,33 @@ impl WasmAuditTrailClientReadOnly { Ok(Self(client)) } + #[wasm_bindgen(js_name = createWithPackageOverrides)] + pub async fn new_with_package_overrides( + iota_client: WasmIotaClient, + package_overrides: WasmPackageOverrides, + ) -> Result { + let package_overrides = PackageOverrides::try_from(package_overrides)?; + let client = AuditTrailClientReadOnly::new_with_package_overrides(iota_client, package_overrides) + .await + .wasm_result()?; + Ok(Self(client)) + } + #[wasm_bindgen(js_name = createWithPkgId)] pub async fn new_with_pkg_id( iota_client: WasmIotaClient, package_id: WasmObjectID, ) -> Result { let package_id = parse_wasm_object_id(&package_id)?; - let client = AuditTrailClientReadOnly::new_with_pkg_id(iota_client, package_id) - .await - .wasm_result()?; + let client = AuditTrailClientReadOnly::new_with_package_overrides( + iota_client, + PackageOverrides { + audit_trail_package_id: Some(package_id), + tf_components_package_id: None, + }, + ) + .await + .wasm_result()?; Ok(Self(client)) } @@ -40,6 +100,11 @@ impl WasmAuditTrailClientReadOnly { self.0.package_id().to_string() } + #[wasm_bindgen(js_name = tfComponentsPackageId)] + pub fn tf_components_package_id(&self) -> String { + self.0.tf_components_package_id().to_string() + } + #[wasm_bindgen(js_name = packageHistory)] pub fn package_history(&self) -> Vec { self.0 diff --git a/bindings/wasm/audit_trails_wasm/src/trail.rs b/bindings/wasm/audit_trails_wasm/src/trail.rs index f8c303b0..62e86b3e 100644 --- a/bindings/wasm/audit_trails_wasm/src/trail.rs +++ b/bindings/wasm/audit_trails_wasm/src/trail.rs @@ -2,8 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 use audit_trails::core::access::{ - CreateRole, DeleteRole, DestroyCapability, DestroyInitialAdminCapability, IssueCapability, RevokeCapability, - RevokeInitialAdminCapability, UpdateRole, + CleanupRevokedCapabilities, CreateRole, DeleteRole, DestroyCapability, DestroyInitialAdminCapability, + IssueCapability, RevokeCapability, RevokeInitialAdminCapability, UpdateRole, }; use audit_trails::core::create::{CreateTrail, TrailCreated}; use audit_trails::core::locking::{ @@ -14,7 +14,7 @@ use audit_trails::core::tags::{AddRecordTag, RemoveRecordTag}; use audit_trails::core::trail::{DeleteAuditTrail, Migrate, UpdateMetadata}; use audit_trails::core::types::{ AuditTrailDeleted, CapabilityDestroyed, CapabilityIssued, CapabilityRevoked, OnChainAuditTrail, RecordAdded, - RecordDeleted, RoleCreated, RoleRemoved, RoleUpdated, + RecordDeleted, RoleCreated, RoleDeleted, RoleUpdated, }; use iota_interaction_ts::bindings::{WasmIotaTransactionBlockEffects, WasmIotaTransactionBlockEvents}; use iota_interaction_ts::core_client::WasmCoreClientReadOnly; @@ -27,7 +27,7 @@ use crate::builder::WasmAuditTrailBuilder; use crate::types::{ WasmAuditTrailDeleted, WasmCapabilityDestroyed, WasmCapabilityIssued, WasmCapabilityRevoked, WasmEmpty, WasmImmutableMetadata, WasmLinkedTable, WasmLockingConfig, WasmRecordAdded, WasmRecordDeleted, WasmRecordTagEntry, - WasmRoleCreated, WasmRoleMap, WasmRoleRemoved, WasmRoleUpdated, + WasmRoleCreated, WasmRoleDeleted, WasmRoleMap, WasmRoleUpdated, }; #[wasm_bindgen(js_name = OnChainAuditTrail, inspectable)] @@ -72,7 +72,12 @@ impl WasmOnChainAuditTrail { #[wasm_bindgen(getter)] pub fn tags(&self) -> Vec { - let mut tags: Vec<_> = self.0.tags.clone().into_iter().map(Into::into).collect(); + let mut tags: Vec = self + .0 + .tags + .iter() + .map(|(tag, usage_count)| (tag.clone(), *usage_count).into()) + .collect(); tags.sort_unstable_by(|left, right| left.tag.cmp(&right.tag)); tags } @@ -350,8 +355,8 @@ impl WasmDeleteRole { wasm_effects: &WasmIotaTransactionBlockEffects, wasm_events: &WasmIotaTransactionBlockEvents, client: &WasmCoreClientReadOnly, - ) -> Result { - let event: RoleRemoved = apply_with_events(self.0, wasm_effects, wasm_events, client).await?; + ) -> Result { + let event: RoleDeleted = apply_with_events(self.0, wasm_effects, wasm_events, client).await?; Ok(event.into()) } } @@ -466,6 +471,27 @@ impl WasmRevokeInitialAdminCapability { } } +#[wasm_bindgen(js_name = CleanupRevokedCapabilities, inspectable)] +pub struct WasmCleanupRevokedCapabilities(pub(crate) CleanupRevokedCapabilities); + +#[wasm_bindgen(js_class = CleanupRevokedCapabilities)] +impl WasmCleanupRevokedCapabilities { + #[wasm_bindgen(js_name = buildProgrammableTransaction)] + pub async fn build_programmable_transaction(&self, client: &WasmCoreClientReadOnly) -> Result> { + build_programmable_transaction(&self.0, client).await + } + + #[wasm_bindgen(js_name = applyWithEvents)] + pub async fn apply_with_events( + self, + wasm_effects: &WasmIotaTransactionBlockEffects, + wasm_events: &WasmIotaTransactionBlockEvents, + client: &WasmCoreClientReadOnly, + ) -> Result { + apply_with_events(self.0, wasm_effects, wasm_events, client).await + } +} + #[wasm_bindgen(js_name = AddRecord, inspectable)] pub struct WasmAddRecord(pub(crate) AddRecord); diff --git a/bindings/wasm/audit_trails_wasm/src/trail_handle/access.rs b/bindings/wasm/audit_trails_wasm/src/trail_handle/access.rs index f8f594f8..c97a0bf2 100644 --- a/bindings/wasm/audit_trails_wasm/src/trail_handle/access.rs +++ b/bindings/wasm/audit_trails_wasm/src/trail_handle/access.rs @@ -12,10 +12,11 @@ use product_common::bindings::WasmObjectID; use wasm_bindgen::prelude::*; use crate::trail::{ - WasmCreateRole, WasmDeleteRole, WasmDestroyCapability, WasmDestroyInitialAdminCapability, WasmIssueCapability, - WasmRevokeCapability, WasmRevokeInitialAdminCapability, WasmUpdateRole, + WasmCleanupRevokedCapabilities, WasmCreateRole, WasmDeleteRole, WasmDestroyCapability, + WasmDestroyInitialAdminCapability, WasmIssueCapability, WasmRevokeCapability, WasmRevokeInitialAdminCapability, + WasmUpdateRole, }; -use crate::types::{WasmCapabilityIssueOptions, WasmPermissionSet, WasmRecordTags}; +use crate::types::{WasmCapabilityIssueOptions, WasmPermissionSet, WasmRoleTags}; #[derive(Clone)] #[wasm_bindgen(js_name = TrailAccess, inspectable)] @@ -49,13 +50,17 @@ impl WasmTrailAccess { } #[wasm_bindgen(js_name = revokeCapability, unchecked_return_type = "TransactionBuilder")] - pub fn revoke_capability(&self, capability_id: WasmObjectID) -> Result { + pub fn revoke_capability( + &self, + capability_id: WasmObjectID, + capability_valid_until: Option, + ) -> Result { let capability_id = parse_wasm_object_id(&capability_id)?; let tx = self .require_write()? .trail(self.trail_id) .access() - .revoke_capability(capability_id) + .revoke_capability(capability_id, capability_valid_until) .into_inner(); Ok(into_transaction_builder(WasmRevokeCapability(tx))) } @@ -85,16 +90,31 @@ impl WasmTrailAccess { } #[wasm_bindgen(js_name = revokeInitialAdminCapability, unchecked_return_type = "TransactionBuilder")] - pub fn revoke_initial_admin_capability(&self, capability_id: WasmObjectID) -> Result { + pub fn revoke_initial_admin_capability( + &self, + capability_id: WasmObjectID, + capability_valid_until: Option, + ) -> Result { let capability_id = parse_wasm_object_id(&capability_id)?; let tx = self .require_write()? .trail(self.trail_id) .access() - .revoke_initial_admin_capability(capability_id) + .revoke_initial_admin_capability(capability_id, capability_valid_until) .into_inner(); Ok(into_transaction_builder(WasmRevokeInitialAdminCapability(tx))) } + + #[wasm_bindgen(js_name = cleanupRevokedCapabilities, unchecked_return_type = "TransactionBuilder")] + pub fn cleanup_revoked_capabilities(&self) -> Result { + let tx = self + .require_write()? + .trail(self.trail_id) + .access() + .cleanup_revoked_capabilities() + .into_inner(); + Ok(into_transaction_builder(WasmCleanupRevokedCapabilities(tx))) + } } #[derive(Clone)] @@ -129,14 +149,14 @@ impl WasmRoleHandle { pub fn create( &self, permissions: WasmPermissionSet, - record_tags: Option, + role_tags: Option, ) -> Result { let tx = self .require_write()? .trail(self.trail_id) .access() .for_role(self.name.clone()) - .create(permissions.into(), record_tags.map(Into::into)) + .create(permissions.into(), role_tags.map(Into::into)) .into_inner(); Ok(into_transaction_builder(WasmCreateRole(tx))) } @@ -157,14 +177,14 @@ impl WasmRoleHandle { pub fn update_permissions( &self, permissions: WasmPermissionSet, - record_tags: Option, + role_tags: Option, ) -> Result { let tx = self .require_write()? .trail(self.trail_id) .access() .for_role(self.name.clone()) - .update_permissions(permissions.into(), record_tags.map(Into::into)) + .update_permissions(permissions.into(), role_tags.map(Into::into)) .into_inner(); Ok(into_transaction_builder(WasmUpdateRole(tx))) } diff --git a/bindings/wasm/audit_trails_wasm/src/trail_handle/mod.rs b/bindings/wasm/audit_trails_wasm/src/trail_handle/mod.rs index 1ed9b0ad..e3a7080c 100644 --- a/bindings/wasm/audit_trails_wasm/src/trail_handle/mod.rs +++ b/bindings/wasm/audit_trails_wasm/src/trail_handle/mod.rs @@ -6,22 +6,21 @@ mod locking; mod records; mod tags; +pub(crate) use access::WasmTrailAccess; use anyhow::anyhow; use audit_trails::{AuditTrailClient, AuditTrailClientReadOnly}; use iota_interaction::types::base_types::ObjectID; use iota_interaction_ts::bindings::WasmTransactionSigner; use iota_interaction_ts::wasm_error::{wasm_error, Result, WasmResult}; +pub(crate) use locking::WasmTrailLocking; use product_common::bindings::transaction::WasmTransactionBuilder; use product_common::bindings::utils::into_transaction_builder; +pub(crate) use records::WasmTrailRecords; +pub(crate) use tags::WasmTrailTags; use wasm_bindgen::prelude::*; use crate::trail::{WasmDeleteAuditTrail, WasmMigrate, WasmOnChainAuditTrail, WasmUpdateMetadata}; -pub(crate) use access::WasmTrailAccess; -pub(crate) use locking::WasmTrailLocking; -pub(crate) use records::WasmTrailRecords; -pub(crate) use tags::WasmTrailTags; - #[derive(Clone)] #[wasm_bindgen(js_name = AuditTrailHandle, inspectable)] pub struct WasmAuditTrailHandle { diff --git a/bindings/wasm/audit_trails_wasm/src/types.rs b/bindings/wasm/audit_trails_wasm/src/types.rs index 8065109d..0aaefdbc 100644 --- a/bindings/wasm/audit_trails_wasm/src/types.rs +++ b/bindings/wasm/audit_trails_wasm/src/types.rs @@ -6,9 +6,10 @@ use std::collections::{HashMap, HashSet}; use audit_trails::core::types::{ AuditTrailCreated, AuditTrailDeleted, Capability, CapabilityAdminPermissions, CapabilityDestroyed, CapabilityIssueOptions, CapabilityIssued, CapabilityRevoked, Data, ImmutableMetadata, LockingConfig, LockingWindow, - PaginatedRecord, Permission, PermissionSet, Record, RecordAdded, RecordCorrection, RecordDeleted, RecordTags, Role, - RoleAdminPermissions, RoleCreated, RoleMap, RoleRemoved, RoleUpdated, TimeLock, + PaginatedRecord, Permission, PermissionSet, Record, RecordAdded, RecordCorrection, RecordDeleted, Role, + RoleAdminPermissions, RoleCreated, RoleDeleted, RoleMap, RoleTags, RoleUpdated, TimeLock, }; +use iota_interaction::types::base_types::ObjectID; use iota_interaction::types::collection_types::LinkedTable; use js_sys::Uint8Array; use product_common::bindings::WasmIotaAddress; @@ -119,13 +120,17 @@ fn sorted_object_ids(ids: HashSet ids } +fn optional_object_id(id: Option) -> Option { + id.map(|id| id.to_string()) +} + fn sorted_role_entries(roles: HashMap) -> Vec { let mut roles: Vec<_> = roles .into_iter() .map(|(name, role)| WasmRolePermissionsEntry { name, permissions: sorted_permissions_from_set(role.permissions), - record_tags: role.data.map(Into::into), + role_tags: role.data.map(Into::into), }) .collect(); roles.sort_unstable_by(|left, right| left.name.cmp(&right.name)); @@ -332,41 +337,38 @@ impl From for WasmCapabilityAdminPermissions { pub struct WasmRolePermissionsEntry { pub name: String, pub permissions: Vec, - #[wasm_bindgen(js_name = recordTags)] - pub record_tags: Option, + #[wasm_bindgen(js_name = roleTags)] + pub role_tags: Option, } -#[wasm_bindgen(js_name = RecordTags, getter_with_clone, inspectable)] +#[wasm_bindgen(js_name = RoleTags, getter_with_clone, inspectable)] #[derive(Clone, Serialize, Deserialize)] -pub struct WasmRecordTags { - #[wasm_bindgen(js_name = allowedTags)] - pub allowed_tags: Vec, +pub struct WasmRoleTags { + pub tags: Vec, } -#[wasm_bindgen(js_class = RecordTags)] -impl WasmRecordTags { +#[wasm_bindgen(js_class = RoleTags)] +impl WasmRoleTags { #[wasm_bindgen(constructor)] - pub fn new(allowed_tags: Vec) -> Self { - let mut allowed_tags = allowed_tags; - allowed_tags.sort_unstable(); - allowed_tags.dedup(); - Self { allowed_tags } + pub fn new(tags: Vec) -> Self { + let mut tags = tags; + tags.sort_unstable(); + tags.dedup(); + Self { tags } } } -impl From for WasmRecordTags { - fn from(value: RecordTags) -> Self { +impl From for WasmRoleTags { + fn from(value: RoleTags) -> Self { Self { - allowed_tags: sorted_tag_names(value.allowed_tags), + tags: sorted_tag_names(value.tags), } } } -impl From for RecordTags { - fn from(value: WasmRecordTags) -> Self { - Self { - allowed_tags: value.allowed_tags.into_iter().collect(), - } +impl From for RoleTags { + fn from(value: WasmRoleTags) -> Self { + RoleTags::new(value.tags) } } @@ -392,8 +394,8 @@ pub struct WasmRoleMap { pub roles: Vec, #[wasm_bindgen(js_name = initialAdminRoleName)] pub initial_admin_role_name: String, - #[wasm_bindgen(js_name = issuedCapabilities)] - pub issued_capabilities: Vec, + #[wasm_bindgen(js_name = revokedCapabilities)] + pub revoked_capabilities: WasmObjectIdLinkedTable, #[wasm_bindgen(js_name = initialAdminCapIds)] pub initial_admin_cap_ids: Vec, #[wasm_bindgen(js_name = roleAdminPermissions)] @@ -408,7 +410,7 @@ impl From for WasmRoleMap { target_key: value.target_key.to_string(), roles: sorted_role_entries(value.roles), initial_admin_role_name: value.initial_admin_role_name, - issued_capabilities: sorted_object_ids(value.issued_capabilities), + revoked_capabilities: value.revoked_capabilities.into(), initial_admin_cap_ids: sorted_object_ids(value.initial_admin_cap_ids), role_admin_permissions: value.role_admin_permissions.into(), capability_admin_permissions: value.capability_admin_permissions.into(), @@ -416,6 +418,26 @@ impl From for WasmRoleMap { } } +#[wasm_bindgen(js_name = ObjectIdLinkedTable, getter_with_clone, inspectable)] +#[derive(Clone, Serialize, Deserialize)] +pub struct WasmObjectIdLinkedTable { + pub id: String, + pub size: u64, + pub head: Option, + pub tail: Option, +} + +impl From> for WasmObjectIdLinkedTable { + fn from(value: LinkedTable) -> Self { + Self { + id: value.id.to_string(), + size: value.size, + head: optional_object_id(value.head), + tail: optional_object_id(value.tail), + } + } +} + #[wasm_bindgen(js_name = CapabilityIssueOptions, getter_with_clone, inspectable)] #[derive(Clone, Default, Serialize, Deserialize)] pub struct WasmCapabilityIssueOptions { @@ -605,6 +627,13 @@ pub struct WasmCapabilityDestroyed { pub target_key: String, #[wasm_bindgen(js_name = capabilityId)] pub capability_id: String, + pub role: String, + #[wasm_bindgen(js_name = issuedTo)] + pub issued_to: Option, + #[wasm_bindgen(js_name = validFrom)] + pub valid_from: Option, + #[wasm_bindgen(js_name = validUntil)] + pub valid_until: Option, } impl From for WasmCapabilityDestroyed { @@ -612,6 +641,10 @@ impl From for WasmCapabilityDestroyed { Self { target_key: value.target_key.to_string(), capability_id: value.capability_id.to_string(), + role: value.role, + issued_to: value.issued_to.map(|address| address.to_string()), + valid_from: value.valid_from, + valid_until: value.valid_until, } } } @@ -623,6 +656,8 @@ pub struct WasmCapabilityRevoked { pub target_key: String, #[wasm_bindgen(js_name = capabilityId)] pub capability_id: String, + #[wasm_bindgen(js_name = validUntil)] + pub valid_until: u64, } impl From for WasmCapabilityRevoked { @@ -630,6 +665,7 @@ impl From for WasmCapabilityRevoked { Self { target_key: value.target_key.to_string(), capability_id: value.capability_id.to_string(), + valid_until: value.valid_until, } } } @@ -640,6 +676,12 @@ pub struct WasmRoleCreated { #[wasm_bindgen(js_name = trailId)] pub trail_id: String, pub role: String, + pub permissions: WasmPermissionSet, + #[wasm_bindgen(js_name = roleTags)] + pub role_tags: Option, + #[wasm_bindgen(js_name = createdBy)] + pub created_by: WasmIotaAddress, + pub timestamp: u64, } impl From for WasmRoleCreated { @@ -647,6 +689,10 @@ impl From for WasmRoleCreated { Self { trail_id: value.trail_id.to_string(), role: value.role, + permissions: value.permissions.into(), + role_tags: value.data.map(Into::into), + created_by: value.created_by.to_string(), + timestamp: value.timestamp, } } } @@ -657,6 +703,12 @@ pub struct WasmRoleUpdated { #[wasm_bindgen(js_name = trailId)] pub trail_id: String, pub role: String, + pub permissions: WasmPermissionSet, + #[wasm_bindgen(js_name = roleTags)] + pub role_tags: Option, + #[wasm_bindgen(js_name = updatedBy)] + pub updated_by: WasmIotaAddress, + pub timestamp: u64, } impl From for WasmRoleUpdated { @@ -664,23 +716,32 @@ impl From for WasmRoleUpdated { Self { trail_id: value.trail_id.to_string(), role: value.role, + permissions: value.permissions.into(), + role_tags: value.data.map(Into::into), + updated_by: value.updated_by.to_string(), + timestamp: value.timestamp, } } } -#[wasm_bindgen(js_name = RoleRemoved, getter_with_clone, inspectable)] +#[wasm_bindgen(js_name = RoleDeleted, getter_with_clone, inspectable)] #[derive(Clone)] -pub struct WasmRoleRemoved { +pub struct WasmRoleDeleted { #[wasm_bindgen(js_name = trailId)] pub trail_id: String, pub role: String, + #[wasm_bindgen(js_name = deletedBy)] + pub deleted_by: WasmIotaAddress, + pub timestamp: u64, } -impl From for WasmRoleRemoved { - fn from(value: RoleRemoved) -> Self { +impl From for WasmRoleDeleted { + fn from(value: RoleDeleted) -> Self { Self { trail_id: value.trail_id.to_string(), role: value.role, + deleted_by: value.deleted_by.to_string(), + timestamp: value.timestamp, } } } From 92f611179dc7ddfe4d4252d8398bbcf0b12f6b22 Mon Sep 17 00:00:00 2001 From: Yasir Date: Fri, 27 Mar 2026 18:18:51 +0300 Subject: [PATCH 106/189] Clean up WASM docs and example formatting --- Cargo.toml | 7 +------ bindings/wasm/audit_trails_wasm/docs/wasm/api_ref.md | 2 +- .../docs/wasm/audit_trails_wasm/api_ref.md | 2 +- .../audit_trails_wasm/classes/DefaultHttpClient.md | 2 +- bindings/wasm/audit_trails_wasm/examples/README.md | 10 +++++----- bindings/wasm/audit_trails_wasm/examples/src/util.ts | 8 ++++---- 6 files changed, 13 insertions(+), 18 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 3fccacdd..a8f861a2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,12 +29,7 @@ serde_json = { version = "1.0", default-features = false } sha2 = { version = "0.10", default-features = false } strum = { version = "0.27", default-features = false, features = ["std", "derive"] } thiserror = { version = "2.0", default-features = false } -tokio = { version = "1.46.1", default-features = false, features = [ - "macros", - "sync", - "rt", - "process", -] } +tokio = { version = "1.46.1", default-features = false, features = ["macros", "sync", "rt", "process"] } [profile.release.package.iota_interaction_ts] opt-level = 's' diff --git a/bindings/wasm/audit_trails_wasm/docs/wasm/api_ref.md b/bindings/wasm/audit_trails_wasm/docs/wasm/api_ref.md index 7762f30e..a0f2a96f 100644 --- a/bindings/wasm/audit_trails_wasm/docs/wasm/api_ref.md +++ b/bindings/wasm/audit_trails_wasm/docs/wasm/api_ref.md @@ -1,6 +1,6 @@ **@iota/audit-trails API documentation** -*** +--- # @iota/audit-trails API documentation diff --git a/bindings/wasm/audit_trails_wasm/docs/wasm/audit_trails_wasm/api_ref.md b/bindings/wasm/audit_trails_wasm/docs/wasm/audit_trails_wasm/api_ref.md index d910b5ce..815fbe32 100644 --- a/bindings/wasm/audit_trails_wasm/docs/wasm/audit_trails_wasm/api_ref.md +++ b/bindings/wasm/audit_trails_wasm/docs/wasm/audit_trails_wasm/api_ref.md @@ -1,6 +1,6 @@ [**@iota/audit-trails API documentation**](../api_ref.md) -*** +--- # audit\_trails\_wasm diff --git a/bindings/wasm/audit_trails_wasm/docs/wasm/audit_trails_wasm/classes/DefaultHttpClient.md b/bindings/wasm/audit_trails_wasm/docs/wasm/audit_trails_wasm/classes/DefaultHttpClient.md index 62216357..0c85f9c5 100644 --- a/bindings/wasm/audit_trails_wasm/docs/wasm/audit_trails_wasm/classes/DefaultHttpClient.md +++ b/bindings/wasm/audit_trails_wasm/docs/wasm/audit_trails_wasm/classes/DefaultHttpClient.md @@ -1,6 +1,6 @@ [**@iota/audit-trails API documentation**](../../api_ref.md) -*** +--- # Class: DefaultHttpClient diff --git a/bindings/wasm/audit_trails_wasm/examples/README.md b/bindings/wasm/audit_trails_wasm/examples/README.md index 797621f9..f28e4a92 100644 --- a/bindings/wasm/audit_trails_wasm/examples/README.md +++ b/bindings/wasm/audit_trails_wasm/examples/README.md @@ -11,11 +11,11 @@ The examples in this folder demonstrate the Core MVP flow of the `@iota/audit-tr Set the following environment variables before running the node examples: -| Name | Required | Description | -| --- | --- | --- | -| `IOTA_AUDIT_TRAIL_PKG_ID` | yes | Package ID of the deployed `audit_trail` Move package | -| `NETWORK_URL` | yes | RPC URL of the IOTA node | -| `NETWORK_NAME_FAUCET` | local/test networks | Faucet alias used by `@iota/iota-sdk` | +| Name | Required | Description | +| ------------------------- | ------------------- | ----------------------------------------------------- | +| `IOTA_AUDIT_TRAIL_PKG_ID` | yes | Package ID of the deployed `audit_trail` Move package | +| `NETWORK_URL` | yes | RPC URL of the IOTA node | +| `NETWORK_NAME_FAUCET` | local/test networks | Faucet alias used by `@iota/iota-sdk` | ## Run diff --git a/bindings/wasm/audit_trails_wasm/examples/src/util.ts b/bindings/wasm/audit_trails_wasm/examples/src/util.ts index 9a699acb..8537c384 100644 --- a/bindings/wasm/audit_trails_wasm/examples/src/util.ts +++ b/bindings/wasm/audit_trails_wasm/examples/src/util.ts @@ -1,10 +1,6 @@ // Copyright 2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -import { Ed25519KeypairSigner } from "@iota/iota-interaction-ts/node/test_utils"; -import { IotaClient } from "@iota/iota-sdk/client"; -import { getFaucetHost, requestIotaFromFaucetV0 } from "@iota/iota-sdk/faucet"; -import { Ed25519Keypair } from "@iota/iota-sdk/keypairs/ed25519"; import { AuditTrailClient, AuditTrailClientReadOnly, @@ -12,6 +8,10 @@ import { LockingWindow, TimeLock, } from "@iota/audit-trails/node"; +import { Ed25519KeypairSigner } from "@iota/iota-interaction-ts/node/test_utils"; +import { IotaClient } from "@iota/iota-sdk/client"; +import { getFaucetHost, requestIotaFromFaucetV0 } from "@iota/iota-sdk/faucet"; +import { Ed25519Keypair } from "@iota/iota-sdk/keypairs/ed25519"; export const IOTA_AUDIT_TRAIL_PKG_ID = globalThis?.process?.env?.IOTA_AUDIT_TRAIL_PKG_ID || ""; export const NETWORK_NAME_FAUCET = globalThis?.process?.env?.NETWORK_NAME_FAUCET || "localnet"; From efa504ec98a0d14d3da70b5f55c91efa2f37216d Mon Sep 17 00:00:00 2001 From: Yasir Date: Mon, 30 Mar 2026 12:33:54 +0300 Subject: [PATCH 107/189] Fix audit trails wasm examples --- audit-trail-move/Move.lock | 6 ++-- .../audit_trails_wasm/docs/wasm/api_ref.md | 2 +- .../docs/wasm/audit_trails_wasm/api_ref.md | 2 +- .../classes/DefaultHttpClient.md | 2 +- .../wasm/audit_trails_wasm/examples/README.md | 16 +++++++---- .../examples/src/03_add_and_list_records.ts | 11 ++++---- .../examples/src/04_delete_records_batch.ts | 11 ++++---- .../audit_trails_wasm/examples/src/util.ts | 28 +++++++++++++++++-- 8 files changed, 53 insertions(+), 25 deletions(-) diff --git a/audit-trail-move/Move.lock b/audit-trail-move/Move.lock index 428052f6..d8d3b616 100644 --- a/audit-trail-move/Move.lock +++ b/audit-trail-move/Move.lock @@ -61,7 +61,7 @@ flavor = "iota" [env] [env.localnet] -chain-id = "ae919a4a" -original-published-id = "0xc948ffc200b507b869f22e38109491abd0ff9c4dc7bf3223cfb04c8da8d96d5e" -latest-published-id = "0xc948ffc200b507b869f22e38109491abd0ff9c4dc7bf3223cfb04c8da8d96d5e" +chain-id = "39f6312e" +original-published-id = "0x148830e6d46618708fcc5065ff2ce1077fe45997cd0bae3f489c9b1ee5571f48" +latest-published-id = "0x148830e6d46618708fcc5065ff2ce1077fe45997cd0bae3f489c9b1ee5571f48" published-version = "1" diff --git a/bindings/wasm/audit_trails_wasm/docs/wasm/api_ref.md b/bindings/wasm/audit_trails_wasm/docs/wasm/api_ref.md index a0f2a96f..7762f30e 100644 --- a/bindings/wasm/audit_trails_wasm/docs/wasm/api_ref.md +++ b/bindings/wasm/audit_trails_wasm/docs/wasm/api_ref.md @@ -1,6 +1,6 @@ **@iota/audit-trails API documentation** ---- +*** # @iota/audit-trails API documentation diff --git a/bindings/wasm/audit_trails_wasm/docs/wasm/audit_trails_wasm/api_ref.md b/bindings/wasm/audit_trails_wasm/docs/wasm/audit_trails_wasm/api_ref.md index 815fbe32..d910b5ce 100644 --- a/bindings/wasm/audit_trails_wasm/docs/wasm/audit_trails_wasm/api_ref.md +++ b/bindings/wasm/audit_trails_wasm/docs/wasm/audit_trails_wasm/api_ref.md @@ -1,6 +1,6 @@ [**@iota/audit-trails API documentation**](../api_ref.md) ---- +*** # audit\_trails\_wasm diff --git a/bindings/wasm/audit_trails_wasm/docs/wasm/audit_trails_wasm/classes/DefaultHttpClient.md b/bindings/wasm/audit_trails_wasm/docs/wasm/audit_trails_wasm/classes/DefaultHttpClient.md index 0c85f9c5..62216357 100644 --- a/bindings/wasm/audit_trails_wasm/docs/wasm/audit_trails_wasm/classes/DefaultHttpClient.md +++ b/bindings/wasm/audit_trails_wasm/docs/wasm/audit_trails_wasm/classes/DefaultHttpClient.md @@ -1,6 +1,6 @@ [**@iota/audit-trails API documentation**](../../api_ref.md) ---- +*** # Class: DefaultHttpClient diff --git a/bindings/wasm/audit_trails_wasm/examples/README.md b/bindings/wasm/audit_trails_wasm/examples/README.md index f28e4a92..3004ddaa 100644 --- a/bindings/wasm/audit_trails_wasm/examples/README.md +++ b/bindings/wasm/audit_trails_wasm/examples/README.md @@ -11,11 +11,12 @@ The examples in this folder demonstrate the Core MVP flow of the `@iota/audit-tr Set the following environment variables before running the node examples: -| Name | Required | Description | -| ------------------------- | ------------------- | ----------------------------------------------------- | -| `IOTA_AUDIT_TRAIL_PKG_ID` | yes | Package ID of the deployed `audit_trail` Move package | -| `NETWORK_URL` | yes | RPC URL of the IOTA node | -| `NETWORK_NAME_FAUCET` | local/test networks | Faucet alias used by `@iota/iota-sdk` | +| Name | Required | Description | +| ---------------------------- | ------------------- | --------------------------------------------------------------------------- | +| `IOTA_AUDIT_TRAIL_PKG_ID` | yes | Package ID of the deployed `audit_trail` Move package | +| `IOTA_TF_COMPONENTS_PKG_ID` | local/custom setups | Package ID of the deployed `TfComponents` package | +| `NETWORK_URL` | yes | RPC URL of the IOTA node | +| `NETWORK_NAME_FAUCET` | local/test networks | Faucet alias used by `@iota/iota-sdk` | ## Run @@ -29,7 +30,10 @@ npm run build Run an example: ```bash -IOTA_AUDIT_TRAIL_PKG_ID= NETWORK_URL=http://127.0.0.1:9000 npm run example:node -- 01_create_trail +IOTA_AUDIT_TRAIL_PKG_ID= \ +IOTA_TF_COMPONENTS_PKG_ID= \ +NETWORK_URL=http://127.0.0.1:9000 \ +npm run example:node -- 01_create_trail ``` Available examples: diff --git a/bindings/wasm/audit_trails_wasm/examples/src/03_add_and_list_records.ts b/bindings/wasm/audit_trails_wasm/examples/src/03_add_and_list_records.ts index 6e8fea76..4fa10ea4 100644 --- a/bindings/wasm/audit_trails_wasm/examples/src/03_add_and_list_records.ts +++ b/bindings/wasm/audit_trails_wasm/examples/src/03_add_and_list_records.ts @@ -2,26 +2,27 @@ // SPDX-License-Identifier: Apache-2.0 import { strict as assert } from "assert"; -import { Data } from "../../web"; -import { createTrailWithSeedRecord, getFundedClient, TEST_GAS_BUDGET } from "./util"; +import { Data } from "@iota/audit-trails/node"; +import { createTrailWithSeedRecord, getFundedClient, grantSelfRecordPermissions, TEST_GAS_BUDGET } from "./util"; export async function addAndListRecords(): Promise { console.log("Adding records and reading them back with pagination"); const client = await getFundedClient(); const { output: trail } = await createTrailWithSeedRecord(client); + await grantSelfRecordPermissions(client, trail.id); const records = client.trail(trail.id).records(); const addedString = await records .add(Data.fromString("record 2"), "second") .withGasBudget(TEST_GAS_BUDGET) .buildAndExecute(client); - const addedBytes = await records - .add(Data.fromBytes(Uint8Array.from([1, 2, 3, 4])), "third") + const addedThird = await records + .add(Data.fromString("record 3"), "third") .withGasBudget(TEST_GAS_BUDGET) .buildAndExecute(client); - console.log("Added records:", addedString.output, addedBytes.output); + console.log("Added records:", addedString.output, addedThird.output); const allRecords = await records.list(); const firstPage = await records.listPage(undefined, 2); diff --git a/bindings/wasm/audit_trails_wasm/examples/src/04_delete_records_batch.ts b/bindings/wasm/audit_trails_wasm/examples/src/04_delete_records_batch.ts index b89f050b..1be851c2 100644 --- a/bindings/wasm/audit_trails_wasm/examples/src/04_delete_records_batch.ts +++ b/bindings/wasm/audit_trails_wasm/examples/src/04_delete_records_batch.ts @@ -2,14 +2,15 @@ // SPDX-License-Identifier: Apache-2.0 import { strict as assert } from "assert"; -import { Data } from "../../web"; -import { createTrailWithSeedRecord, getFundedClient, TEST_GAS_BUDGET } from "./util"; +import { Data } from "@iota/audit-trails/node"; +import { createTrailWithSeedRecord, getFundedClient, grantSelfRecordPermissions, TEST_GAS_BUDGET } from "./util"; export async function deleteRecordsBatch(): Promise { console.log("Deleting records in batch"); const client = await getFundedClient(); const { output: trail } = await createTrailWithSeedRecord(client); + await grantSelfRecordPermissions(client, trail.id); const records = client.trail(trail.id).records(); await records.add(Data.fromString("record 2"), "second").withGasBudget(TEST_GAS_BUDGET).buildAndExecute(client); @@ -21,7 +22,7 @@ export async function deleteRecordsBatch(): Promise { console.log(`Deleted ${deleted.output} records. Count before=${before}, after=${after}`); - assert.equal(before, 3); - assert.equal(deleted.output, 2); - assert.equal(after, 1); + assert.equal(before, 3n); + assert.equal(deleted.output, 2n); + assert.equal(after, 1n); } diff --git a/bindings/wasm/audit_trails_wasm/examples/src/util.ts b/bindings/wasm/audit_trails_wasm/examples/src/util.ts index 8537c384..f42ed381 100644 --- a/bindings/wasm/audit_trails_wasm/examples/src/util.ts +++ b/bindings/wasm/audit_trails_wasm/examples/src/util.ts @@ -4,8 +4,12 @@ import { AuditTrailClient, AuditTrailClientReadOnly, + CapabilityIssueOptions, LockingConfig, LockingWindow, + PackageOverrides, + Permission, + PermissionSet, TimeLock, } from "@iota/audit-trails/node"; import { Ed25519KeypairSigner } from "@iota/iota-interaction-ts/node/test_utils"; @@ -13,13 +17,15 @@ import { IotaClient } from "@iota/iota-sdk/client"; import { getFaucetHost, requestIotaFromFaucetV0 } from "@iota/iota-sdk/faucet"; import { Ed25519Keypair } from "@iota/iota-sdk/keypairs/ed25519"; + export const IOTA_AUDIT_TRAIL_PKG_ID = globalThis?.process?.env?.IOTA_AUDIT_TRAIL_PKG_ID || ""; +export const IOTA_TF_COMPONENTS_PKG_ID = globalThis?.process?.env?.IOTA_TF_COMPONENTS_PKG_ID || ""; export const NETWORK_NAME_FAUCET = globalThis?.process?.env?.NETWORK_NAME_FAUCET || "localnet"; export const NETWORK_URL = globalThis?.process?.env?.NETWORK_URL || "http://127.0.0.1:9000"; export const TEST_GAS_BUDGET = BigInt(50_000_000); -if (!IOTA_AUDIT_TRAIL_PKG_ID) { - throw new Error("IOTA_AUDIT_TRAIL_PKG_ID env variable must be set to run the examples"); +if (!IOTA_AUDIT_TRAIL_PKG_ID || !IOTA_TF_COMPONENTS_PKG_ID) { + throw new Error("IOTA_AUDIT_TRAIL_PKG_ID and IOTA_TF_COMPONENTS_PKG_ID env variables must be set to run the examples"); } export async function requestFunds(address: string) { @@ -31,7 +37,7 @@ export async function requestFunds(address: string) { export async function getReadOnlyClient(): Promise { const iotaClient = new IotaClient({ url: NETWORK_URL }); - return AuditTrailClientReadOnly.createWithPkgId(iotaClient, IOTA_AUDIT_TRAIL_PKG_ID); + return AuditTrailClientReadOnly.createWithPackageOverrides(iotaClient, new PackageOverrides(IOTA_AUDIT_TRAIL_PKG_ID, IOTA_TF_COMPONENTS_PKG_ID)); } export async function getFundedClient(): Promise { @@ -70,3 +76,19 @@ export async function createTrailWithSeedRecord(client: AuditTrailClient) { .withGasBudget(TEST_GAS_BUDGET) .buildAndExecute(client); } + +export async function grantSelfRecordPermissions(client: AuditTrailClient, trailId: string): Promise { + const role = client.trail(trailId).access().forRole("example-record-writer"); + const permissions = new PermissionSet([ + Permission.AddRecord, + Permission.DeleteRecord, + Permission.DeleteAllRecords, + Permission.CorrectRecord, + ]); + + await role.create(permissions).withGasBudget(TEST_GAS_BUDGET).buildAndExecute(client); + await role + .issueCapability(new CapabilityIssueOptions(client.senderAddress())) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); +} From b54f88b99a5625f3c4fb9e238b2a7f22ecb98a07 Mon Sep 17 00:00:00 2001 From: Yasir Date: Mon, 30 Mar 2026 12:42:40 +0300 Subject: [PATCH 108/189] chore: fmt and dprint fixes --- bindings/wasm/audit_trails_wasm/docs/wasm/api_ref.md | 2 +- .../docs/wasm/audit_trails_wasm/api_ref.md | 2 +- .../audit_trails_wasm/classes/DefaultHttpClient.md | 2 +- bindings/wasm/audit_trails_wasm/examples/README.md | 12 ++++++------ .../examples/src/03_add_and_list_records.ts | 2 +- .../examples/src/04_delete_records_batch.ts | 2 +- bindings/wasm/audit_trails_wasm/examples/src/util.ts | 10 +++++++--- 7 files changed, 18 insertions(+), 14 deletions(-) diff --git a/bindings/wasm/audit_trails_wasm/docs/wasm/api_ref.md b/bindings/wasm/audit_trails_wasm/docs/wasm/api_ref.md index 7762f30e..a0f2a96f 100644 --- a/bindings/wasm/audit_trails_wasm/docs/wasm/api_ref.md +++ b/bindings/wasm/audit_trails_wasm/docs/wasm/api_ref.md @@ -1,6 +1,6 @@ **@iota/audit-trails API documentation** -*** +--- # @iota/audit-trails API documentation diff --git a/bindings/wasm/audit_trails_wasm/docs/wasm/audit_trails_wasm/api_ref.md b/bindings/wasm/audit_trails_wasm/docs/wasm/audit_trails_wasm/api_ref.md index d910b5ce..815fbe32 100644 --- a/bindings/wasm/audit_trails_wasm/docs/wasm/audit_trails_wasm/api_ref.md +++ b/bindings/wasm/audit_trails_wasm/docs/wasm/audit_trails_wasm/api_ref.md @@ -1,6 +1,6 @@ [**@iota/audit-trails API documentation**](../api_ref.md) -*** +--- # audit\_trails\_wasm diff --git a/bindings/wasm/audit_trails_wasm/docs/wasm/audit_trails_wasm/classes/DefaultHttpClient.md b/bindings/wasm/audit_trails_wasm/docs/wasm/audit_trails_wasm/classes/DefaultHttpClient.md index 62216357..0c85f9c5 100644 --- a/bindings/wasm/audit_trails_wasm/docs/wasm/audit_trails_wasm/classes/DefaultHttpClient.md +++ b/bindings/wasm/audit_trails_wasm/docs/wasm/audit_trails_wasm/classes/DefaultHttpClient.md @@ -1,6 +1,6 @@ [**@iota/audit-trails API documentation**](../../api_ref.md) -*** +--- # Class: DefaultHttpClient diff --git a/bindings/wasm/audit_trails_wasm/examples/README.md b/bindings/wasm/audit_trails_wasm/examples/README.md index 3004ddaa..6a440758 100644 --- a/bindings/wasm/audit_trails_wasm/examples/README.md +++ b/bindings/wasm/audit_trails_wasm/examples/README.md @@ -11,12 +11,12 @@ The examples in this folder demonstrate the Core MVP flow of the `@iota/audit-tr Set the following environment variables before running the node examples: -| Name | Required | Description | -| ---------------------------- | ------------------- | --------------------------------------------------------------------------- | -| `IOTA_AUDIT_TRAIL_PKG_ID` | yes | Package ID of the deployed `audit_trail` Move package | -| `IOTA_TF_COMPONENTS_PKG_ID` | local/custom setups | Package ID of the deployed `TfComponents` package | -| `NETWORK_URL` | yes | RPC URL of the IOTA node | -| `NETWORK_NAME_FAUCET` | local/test networks | Faucet alias used by `@iota/iota-sdk` | +| Name | Required | Description | +| --------------------------- | ------------------- | ----------------------------------------------------- | +| `IOTA_AUDIT_TRAIL_PKG_ID` | yes | Package ID of the deployed `audit_trail` Move package | +| `IOTA_TF_COMPONENTS_PKG_ID` | local/custom setups | Package ID of the deployed `TfComponents` package | +| `NETWORK_URL` | yes | RPC URL of the IOTA node | +| `NETWORK_NAME_FAUCET` | local/test networks | Faucet alias used by `@iota/iota-sdk` | ## Run diff --git a/bindings/wasm/audit_trails_wasm/examples/src/03_add_and_list_records.ts b/bindings/wasm/audit_trails_wasm/examples/src/03_add_and_list_records.ts index 4fa10ea4..5a2b4df7 100644 --- a/bindings/wasm/audit_trails_wasm/examples/src/03_add_and_list_records.ts +++ b/bindings/wasm/audit_trails_wasm/examples/src/03_add_and_list_records.ts @@ -1,8 +1,8 @@ // Copyright 2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -import { strict as assert } from "assert"; import { Data } from "@iota/audit-trails/node"; +import { strict as assert } from "assert"; import { createTrailWithSeedRecord, getFundedClient, grantSelfRecordPermissions, TEST_GAS_BUDGET } from "./util"; export async function addAndListRecords(): Promise { diff --git a/bindings/wasm/audit_trails_wasm/examples/src/04_delete_records_batch.ts b/bindings/wasm/audit_trails_wasm/examples/src/04_delete_records_batch.ts index 1be851c2..dc63dcbd 100644 --- a/bindings/wasm/audit_trails_wasm/examples/src/04_delete_records_batch.ts +++ b/bindings/wasm/audit_trails_wasm/examples/src/04_delete_records_batch.ts @@ -1,8 +1,8 @@ // Copyright 2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -import { strict as assert } from "assert"; import { Data } from "@iota/audit-trails/node"; +import { strict as assert } from "assert"; import { createTrailWithSeedRecord, getFundedClient, grantSelfRecordPermissions, TEST_GAS_BUDGET } from "./util"; export async function deleteRecordsBatch(): Promise { diff --git a/bindings/wasm/audit_trails_wasm/examples/src/util.ts b/bindings/wasm/audit_trails_wasm/examples/src/util.ts index f42ed381..54da7875 100644 --- a/bindings/wasm/audit_trails_wasm/examples/src/util.ts +++ b/bindings/wasm/audit_trails_wasm/examples/src/util.ts @@ -17,7 +17,6 @@ import { IotaClient } from "@iota/iota-sdk/client"; import { getFaucetHost, requestIotaFromFaucetV0 } from "@iota/iota-sdk/faucet"; import { Ed25519Keypair } from "@iota/iota-sdk/keypairs/ed25519"; - export const IOTA_AUDIT_TRAIL_PKG_ID = globalThis?.process?.env?.IOTA_AUDIT_TRAIL_PKG_ID || ""; export const IOTA_TF_COMPONENTS_PKG_ID = globalThis?.process?.env?.IOTA_TF_COMPONENTS_PKG_ID || ""; export const NETWORK_NAME_FAUCET = globalThis?.process?.env?.NETWORK_NAME_FAUCET || "localnet"; @@ -25,7 +24,9 @@ export const NETWORK_URL = globalThis?.process?.env?.NETWORK_URL || "http://127. export const TEST_GAS_BUDGET = BigInt(50_000_000); if (!IOTA_AUDIT_TRAIL_PKG_ID || !IOTA_TF_COMPONENTS_PKG_ID) { - throw new Error("IOTA_AUDIT_TRAIL_PKG_ID and IOTA_TF_COMPONENTS_PKG_ID env variables must be set to run the examples"); + throw new Error( + "IOTA_AUDIT_TRAIL_PKG_ID and IOTA_TF_COMPONENTS_PKG_ID env variables must be set to run the examples", + ); } export async function requestFunds(address: string) { @@ -37,7 +38,10 @@ export async function requestFunds(address: string) { export async function getReadOnlyClient(): Promise { const iotaClient = new IotaClient({ url: NETWORK_URL }); - return AuditTrailClientReadOnly.createWithPackageOverrides(iotaClient, new PackageOverrides(IOTA_AUDIT_TRAIL_PKG_ID, IOTA_TF_COMPONENTS_PKG_ID)); + return AuditTrailClientReadOnly.createWithPackageOverrides( + iotaClient, + new PackageOverrides(IOTA_AUDIT_TRAIL_PKG_ID, IOTA_TF_COMPONENTS_PKG_ID), + ); } export async function getFundedClient(): Promise { From ab94ddc81993b855ac387a503033de3839901b50 Mon Sep 17 00:00:00 2001 From: chrisgitiota Date: Mon, 30 Mar 2026 14:26:58 +0200 Subject: [PATCH 109/189] Rename `trails` to `trail` --- Cargo.toml | 2 +- audit-trail-move/sources/audit_trail.move | 2 +- audit-trail-rs/Cargo.toml | 4 ++-- audit-trail-rs/README.md | 2 +- audit-trail-rs/src/client/full_client.rs | 2 +- audit-trail-rs/src/core/mod.rs | 2 +- audit-trail-rs/src/core/types/mod.rs | 2 +- audit-trail-rs/tests/e2e/access.rs | 2 +- audit-trail-rs/tests/e2e/client.rs | 4 ++-- audit-trail-rs/tests/e2e/locking.rs | 2 +- audit-trail-rs/tests/e2e/records.rs | 4 ++-- audit-trail-rs/tests/e2e/trail.rs | 2 +- bindings/wasm/audit_trails_wasm/Cargo.toml | 6 +++--- bindings/wasm/audit_trails_wasm/README.md | 4 ++-- .../wasm/audit_trails_wasm/docs/wasm/api_ref.md | 6 +++--- .../docs/wasm/audit_trails_wasm/api_ref.md | 4 ++-- .../audit_trails_wasm/classes/DefaultHttpClient.md | 2 +- bindings/wasm/audit_trails_wasm/examples/README.md | 4 ++-- .../examples/src/03_add_and_list_records.ts | 2 +- .../examples/src/04_delete_records_batch.ts | 2 +- .../wasm/audit_trails_wasm/examples/src/util.ts | 2 +- .../audit_trails_wasm/examples/tsconfig.node.json | 2 +- .../audit_trails_wasm/examples/tsconfig.web.json | 2 +- bindings/wasm/audit_trails_wasm/lib/index.ts | 2 +- bindings/wasm/audit_trails_wasm/lib/tsconfig.json | 6 +++--- .../wasm/audit_trails_wasm/lib/tsconfig.web.json | 6 +++--- bindings/wasm/audit_trails_wasm/package-lock.json | 4 ++-- bindings/wasm/audit_trails_wasm/package.json | 14 +++++++------- bindings/wasm/audit_trails_wasm/src/builder.rs | 2 +- bindings/wasm/audit_trails_wasm/src/client.rs | 2 +- .../wasm/audit_trails_wasm/src/client_read_only.rs | 2 +- bindings/wasm/audit_trails_wasm/src/trail.rs | 14 +++++++------- .../audit_trails_wasm/src/trail_handle/access.rs | 2 +- .../audit_trails_wasm/src/trail_handle/locking.rs | 2 +- .../wasm/audit_trails_wasm/src/trail_handle/mod.rs | 2 +- .../audit_trails_wasm/src/trail_handle/records.rs | 4 ++-- .../audit_trails_wasm/src/trail_handle/tags.rs | 2 +- bindings/wasm/audit_trails_wasm/src/types.rs | 2 +- bindings/wasm/audit_trails_wasm/tsconfig.json | 2 +- bindings/wasm/audit_trails_wasm/typedoc.json | 2 +- 40 files changed, 68 insertions(+), 68 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index a8f861a2..bfbf755b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ rust-version = "1.85" [workspace] resolver = "2" members = ["audit-trail-rs", "examples", "notarization-rs"] -exclude = ["bindings/wasm/notarization_wasm", "bindings/wasm/audit_trails_wasm"] +exclude = ["bindings/wasm/notarization_wasm", "bindings/wasm/audit_trail_wasm"] [workspace.dependencies] anyhow = "1.0" diff --git a/audit-trail-move/sources/audit_trail.move b/audit-trail-move/sources/audit_trail.move index ddc15a36..89d1b516 100644 --- a/audit-trail-move/sources/audit_trail.move +++ b/audit-trail-move/sources/audit_trail.move @@ -1,7 +1,7 @@ // Copyright (c) 2025 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -/// Audit Trails with role-based access control and timelock +/// Audit Trail with role-based access control and timelock /// A trail is a tamper-proof, sequential chain of notarized records where each /// entry references its predecessor, ensuring verifiable continuity and /// integrity. diff --git a/audit-trail-rs/Cargo.toml b/audit-trail-rs/Cargo.toml index 475e652b..eb997a0e 100644 --- a/audit-trail-rs/Cargo.toml +++ b/audit-trail-rs/Cargo.toml @@ -1,10 +1,10 @@ [package] -name = "audit_trails" +name = "audit_trail" version = "0.1.0-alpha" authors.workspace = true edition.workspace = true homepage.workspace = true -keywords = ["iota", "tangle", "utxo", "audit-trail", "audit-trails"] +keywords = ["iota", "tangle", "utxo", "audit-trail", "audit-trail"] license.workspace = true readme = "./README.md" repository.workspace = true diff --git a/audit-trail-rs/README.md b/audit-trail-rs/README.md index fe437cd3..97a329c2 100644 --- a/audit-trail-rs/README.md +++ b/audit-trail-rs/README.md @@ -1 +1 @@ -# IOTA Audit Trails (WIP) +# IOTA Audit Trail (WIP) diff --git a/audit-trail-rs/src/client/full_client.rs b/audit-trail-rs/src/client/full_client.rs index cf0dd34b..2d2a61b3 100644 --- a/audit-trail-rs/src/client/full_client.rs +++ b/audit-trail-rs/src/client/full_client.rs @@ -83,7 +83,7 @@ impl AuditTrailClient { /// /// # Examples /// ```rust,ignore - /// # use audit_trails::client::AuditTrailClient; + /// # use audit_trail::client::AuditTrailClient; /// /// # #[tokio::main] /// # async fn main() -> anyhow::Result<()> { diff --git a/audit-trail-rs/src/core/mod.rs b/audit-trail-rs/src/core/mod.rs index 03ba5155..a34108c5 100644 --- a/audit-trail-rs/src/core/mod.rs +++ b/audit-trail-rs/src/core/mod.rs @@ -1,7 +1,7 @@ // Copyright 2020-2025 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -//! Core types and builders for audit trails. +//! Core types and builders for audit trail. pub mod access; pub mod builder; diff --git a/audit-trail-rs/src/core/types/mod.rs b/audit-trail-rs/src/core/types/mod.rs index 3a13679c..ef9dfdcc 100644 --- a/audit-trail-rs/src/core/types/mod.rs +++ b/audit-trail-rs/src/core/types/mod.rs @@ -1,7 +1,7 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -//! Core data types for audit trails. +//! Core data types for audit trail. pub mod audit_trail; pub mod event; diff --git a/audit-trail-rs/tests/e2e/access.rs b/audit-trail-rs/tests/e2e/access.rs index 252f4084..cfcff048 100644 --- a/audit-trail-rs/tests/e2e/access.rs +++ b/audit-trail-rs/tests/e2e/access.rs @@ -3,7 +3,7 @@ use std::collections::HashSet; -use audit_trails::core::types::{CapabilityIssueOptions, Data, Permission, PermissionSet, RoleTags}; +use audit_trail::core::types::{CapabilityIssueOptions, Data, Permission, PermissionSet, RoleTags}; use iota_interaction::types::base_types::IotaAddress; use product_common::core_client::CoreClient; diff --git a/audit-trail-rs/tests/e2e/client.rs b/audit-trail-rs/tests/e2e/client.rs index d86e4950..2b88f6d0 100644 --- a/audit-trail-rs/tests/e2e/client.rs +++ b/audit-trail-rs/tests/e2e/client.rs @@ -7,11 +7,11 @@ use std::str::FromStr; use std::sync::Arc; use anyhow::{Context, anyhow}; -use audit_trails::core::types::{ +use audit_trail::core::types::{ Capability, CapabilityIssueOptions, CapabilityIssued, Data, InitialRecord, Permission, PermissionSet, RoleCreated, RoleTags, }; -use audit_trails::{AuditTrailClient, PackageOverrides}; +use audit_trail::{AuditTrailClient, PackageOverrides}; use iota_interaction::types::base_types::{IotaAddress, ObjectID, ObjectRef}; use iota_interaction::types::crypto::PublicKey; use iota_interaction::{IOTA_LOCAL_NETWORK_URL, IotaClient, IotaClientBuilder}; diff --git a/audit-trail-rs/tests/e2e/locking.rs b/audit-trail-rs/tests/e2e/locking.rs index 7295a65f..2d70f8f0 100644 --- a/audit-trail-rs/tests/e2e/locking.rs +++ b/audit-trail-rs/tests/e2e/locking.rs @@ -1,7 +1,7 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use audit_trails::core::types::{ +use audit_trail::core::types::{ CapabilityIssueOptions, Data, InitialRecord, LockingConfig, LockingWindow, Permission, TimeLock, }; use iota_interaction::types::base_types::ObjectID; diff --git a/audit-trail-rs/tests/e2e/records.rs b/audit-trail-rs/tests/e2e/records.rs index f17343b5..eb1f14e9 100644 --- a/audit-trail-rs/tests/e2e/records.rs +++ b/audit-trail-rs/tests/e2e/records.rs @@ -1,10 +1,10 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use audit_trails::core::types::{ +use audit_trail::core::types::{ CapabilityIssueOptions, Data, InitialRecord, LockingConfig, LockingWindow, Permission, RoleTags, TimeLock, }; -use audit_trails::error::Error; +use audit_trail::error::Error; use iota_interaction::types::base_types::ObjectID; use product_common::core_client::CoreClient; diff --git a/audit-trail-rs/tests/e2e/trail.rs b/audit-trail-rs/tests/e2e/trail.rs index ab4d9fd6..b5baea04 100644 --- a/audit-trail-rs/tests/e2e/trail.rs +++ b/audit-trail-rs/tests/e2e/trail.rs @@ -1,7 +1,7 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use audit_trails::core::types::{ +use audit_trail::core::types::{ CapabilityIssueOptions, Data, ImmutableMetadata, InitialRecord, LockingConfig, LockingWindow, Permission, RoleTags, TimeLock, }; diff --git a/bindings/wasm/audit_trails_wasm/Cargo.toml b/bindings/wasm/audit_trails_wasm/Cargo.toml index 92be7df8..bb509c64 100644 --- a/bindings/wasm/audit_trails_wasm/Cargo.toml +++ b/bindings/wasm/audit_trails_wasm/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "audit_trails_wasm" +name = "audit_trail_wasm" version = "0.1.0-alpha" authors = ["IOTA Stiftung"] edition = "2021" @@ -10,14 +10,14 @@ publish = false readme = "README.md" repository = "https://github.com/iotaledger/notarization.git" resolver = "2" -description = "Web Assembly bindings for the audit_trails crate." +description = "Web Assembly bindings for the audit_trail crate." [lib] crate-type = ["cdylib", "rlib"] [dependencies] anyhow = "1.0.95" -audit_trails = { path = "../../../audit-trail-rs", default-features = false, features = ["gas-station", "default-http-client"] } +audit_trail = { path = "../../../audit-trail-rs", default-features = false, features = ["gas-station", "default-http-client"] } bcs = "0.1.6" console_error_panic_hook = { version = "0.1" } iota_interaction = { git = "https://github.com/iotaledger/product-core.git", branch = "feat/tf-compoenents-dev", package = "iota_interaction", default-features = false } diff --git a/bindings/wasm/audit_trails_wasm/README.md b/bindings/wasm/audit_trails_wasm/README.md index 6cfde375..b06586e7 100644 --- a/bindings/wasm/audit_trails_wasm/README.md +++ b/bindings/wasm/audit_trails_wasm/README.md @@ -1,6 +1,6 @@ -# IOTA Audit Trails WASM Library +# IOTA Audit Trail WASM Library -`audit_trails_wasm` provides the Rust-to-WASM bindings for the `audit_trails` crate and is published to JavaScript consumers as `@iota/audit-trails`. +`audit_trail_wasm` provides the Rust-to-WASM bindings for the `audit_trail` crate and is published to JavaScript consumers as `@iota/audit-trail`. The current MVP surface includes: diff --git a/bindings/wasm/audit_trails_wasm/docs/wasm/api_ref.md b/bindings/wasm/audit_trails_wasm/docs/wasm/api_ref.md index a0f2a96f..95a4cd62 100644 --- a/bindings/wasm/audit_trails_wasm/docs/wasm/api_ref.md +++ b/bindings/wasm/audit_trails_wasm/docs/wasm/api_ref.md @@ -1,9 +1,9 @@ -**@iota/audit-trails API documentation** +**@iota/audit-trail API documentation** --- -# @iota/audit-trails API documentation +# @iota/audit-trail API documentation ## Modules -- [audit\_trails\_wasm](audit_trails_wasm/api_ref.md) +- [audit\_trail\_wasm](audit_trail_wasm/api_ref.md) diff --git a/bindings/wasm/audit_trails_wasm/docs/wasm/audit_trails_wasm/api_ref.md b/bindings/wasm/audit_trails_wasm/docs/wasm/audit_trails_wasm/api_ref.md index 815fbe32..e9c5b2e1 100644 --- a/bindings/wasm/audit_trails_wasm/docs/wasm/audit_trails_wasm/api_ref.md +++ b/bindings/wasm/audit_trails_wasm/docs/wasm/audit_trails_wasm/api_ref.md @@ -1,8 +1,8 @@ -[**@iota/audit-trails API documentation**](../api_ref.md) +[**@iota/audit-trail API documentation**](../api_ref.md) --- -# audit\_trails\_wasm +# audit\_trail\_wasm ## Classes diff --git a/bindings/wasm/audit_trails_wasm/docs/wasm/audit_trails_wasm/classes/DefaultHttpClient.md b/bindings/wasm/audit_trails_wasm/docs/wasm/audit_trails_wasm/classes/DefaultHttpClient.md index 0c85f9c5..d3d974e5 100644 --- a/bindings/wasm/audit_trails_wasm/docs/wasm/audit_trails_wasm/classes/DefaultHttpClient.md +++ b/bindings/wasm/audit_trails_wasm/docs/wasm/audit_trails_wasm/classes/DefaultHttpClient.md @@ -1,4 +1,4 @@ -[**@iota/audit-trails API documentation**](../../api_ref.md) +[**@iota/audit-trail API documentation**](../../api_ref.md) --- diff --git a/bindings/wasm/audit_trails_wasm/examples/README.md b/bindings/wasm/audit_trails_wasm/examples/README.md index 6a440758..e6ffeb6d 100644 --- a/bindings/wasm/audit_trails_wasm/examples/README.md +++ b/bindings/wasm/audit_trails_wasm/examples/README.md @@ -1,6 +1,6 @@ -# IOTA Audit Trails WASM Examples +# IOTA Audit Trail WASM Examples -The examples in this folder demonstrate the Core MVP flow of the `@iota/audit-trails` package: +The examples in this folder demonstrate the Core MVP flow of the `@iota/audit-trail` package: - create a trail - fetch a trail diff --git a/bindings/wasm/audit_trails_wasm/examples/src/03_add_and_list_records.ts b/bindings/wasm/audit_trails_wasm/examples/src/03_add_and_list_records.ts index 5a2b4df7..e516d26c 100644 --- a/bindings/wasm/audit_trails_wasm/examples/src/03_add_and_list_records.ts +++ b/bindings/wasm/audit_trails_wasm/examples/src/03_add_and_list_records.ts @@ -1,7 +1,7 @@ // Copyright 2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -import { Data } from "@iota/audit-trails/node"; +import { Data } from "@iota/audit-trail/node"; import { strict as assert } from "assert"; import { createTrailWithSeedRecord, getFundedClient, grantSelfRecordPermissions, TEST_GAS_BUDGET } from "./util"; diff --git a/bindings/wasm/audit_trails_wasm/examples/src/04_delete_records_batch.ts b/bindings/wasm/audit_trails_wasm/examples/src/04_delete_records_batch.ts index dc63dcbd..29dc8147 100644 --- a/bindings/wasm/audit_trails_wasm/examples/src/04_delete_records_batch.ts +++ b/bindings/wasm/audit_trails_wasm/examples/src/04_delete_records_batch.ts @@ -1,7 +1,7 @@ // Copyright 2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -import { Data } from "@iota/audit-trails/node"; +import { Data } from "@iota/audit-trail/node"; import { strict as assert } from "assert"; import { createTrailWithSeedRecord, getFundedClient, grantSelfRecordPermissions, TEST_GAS_BUDGET } from "./util"; diff --git a/bindings/wasm/audit_trails_wasm/examples/src/util.ts b/bindings/wasm/audit_trails_wasm/examples/src/util.ts index 54da7875..96fc9415 100644 --- a/bindings/wasm/audit_trails_wasm/examples/src/util.ts +++ b/bindings/wasm/audit_trails_wasm/examples/src/util.ts @@ -11,7 +11,7 @@ import { Permission, PermissionSet, TimeLock, -} from "@iota/audit-trails/node"; +} from "@iota/audit-trail/node"; import { Ed25519KeypairSigner } from "@iota/iota-interaction-ts/node/test_utils"; import { IotaClient } from "@iota/iota-sdk/client"; import { getFaucetHost, requestIotaFromFaucetV0 } from "@iota/iota-sdk/faucet"; diff --git a/bindings/wasm/audit_trails_wasm/examples/tsconfig.node.json b/bindings/wasm/audit_trails_wasm/examples/tsconfig.node.json index 3250200f..e82ad401 100644 --- a/bindings/wasm/audit_trails_wasm/examples/tsconfig.node.json +++ b/bindings/wasm/audit_trails_wasm/examples/tsconfig.node.json @@ -11,7 +11,7 @@ "moduleResolution": "node", "skipLibCheck": true, "paths": { - "@iota/audit-trails/node": [ + "@iota/audit-trail/node": [ "../node" ] } diff --git a/bindings/wasm/audit_trails_wasm/examples/tsconfig.web.json b/bindings/wasm/audit_trails_wasm/examples/tsconfig.web.json index b4f376cd..4bfdc7c0 100644 --- a/bindings/wasm/audit_trails_wasm/examples/tsconfig.web.json +++ b/bindings/wasm/audit_trails_wasm/examples/tsconfig.web.json @@ -11,7 +11,7 @@ "moduleResolution": "node", "skipLibCheck": true, "paths": { - "@iota/audit-trails/node": [ + "@iota/audit-trail/node": [ "../web" ] } diff --git a/bindings/wasm/audit_trails_wasm/lib/index.ts b/bindings/wasm/audit_trails_wasm/lib/index.ts index 44e60299..dbbf1ead 100644 --- a/bindings/wasm/audit_trails_wasm/lib/index.ts +++ b/bindings/wasm/audit_trails_wasm/lib/index.ts @@ -2,4 +2,4 @@ // SPDX-License-Identifier: Apache-2.0 export * from "@iota/iota-interaction-ts/transaction_internal"; -export * from "~audit_trails_wasm"; +export * from "~audit_trail_wasm"; diff --git a/bindings/wasm/audit_trails_wasm/lib/tsconfig.json b/bindings/wasm/audit_trails_wasm/lib/tsconfig.json index bdfee95b..5c1e30af 100644 --- a/bindings/wasm/audit_trails_wasm/lib/tsconfig.json +++ b/bindings/wasm/audit_trails_wasm/lib/tsconfig.json @@ -3,9 +3,9 @@ "compilerOptions": { "baseUrl": "./", "paths": { - "~audit_trails_wasm": [ - "../node/audit_trails_wasm", - "./audit_trails_wasm.js" + "~audit_trail_wasm": [ + "../node/audit_trail_wasm", + "./audit_trail_wasm.js" ], "@iota/iota-interaction-ts/*": [ "../node_modules/@iota/iota-interaction-ts/node/*", diff --git a/bindings/wasm/audit_trails_wasm/lib/tsconfig.web.json b/bindings/wasm/audit_trails_wasm/lib/tsconfig.web.json index c7c466a4..1621088d 100644 --- a/bindings/wasm/audit_trails_wasm/lib/tsconfig.web.json +++ b/bindings/wasm/audit_trails_wasm/lib/tsconfig.web.json @@ -3,9 +3,9 @@ "compilerOptions": { "baseUrl": "./", "paths": { - "~audit_trails_wasm": [ - "../web/audit_trails_wasm", - "./audit_trails_wasm.js" + "~audit_trail_wasm": [ + "../web/audit_trail_wasm", + "./audit_trail_wasm.js" ], "@iota/iota-interaction-ts/*": [ "../node_modules/@iota/iota-interaction-ts/web/*", diff --git a/bindings/wasm/audit_trails_wasm/package-lock.json b/bindings/wasm/audit_trails_wasm/package-lock.json index 1ecfb89c..3253e8af 100644 --- a/bindings/wasm/audit_trails_wasm/package-lock.json +++ b/bindings/wasm/audit_trails_wasm/package-lock.json @@ -1,11 +1,11 @@ { - "name": "@iota/audit-trails", + "name": "@iota/audit-trail", "version": "0.1.0-alpha", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@iota/audit-trails", + "name": "@iota/audit-trail", "version": "0.1.0-alpha", "license": "Apache-2.0", "dependencies": { diff --git a/bindings/wasm/audit_trails_wasm/package.json b/bindings/wasm/audit_trails_wasm/package.json index 7ae5c887..e0adc952 100644 --- a/bindings/wasm/audit_trails_wasm/package.json +++ b/bindings/wasm/audit_trails_wasm/package.json @@ -1,7 +1,7 @@ { - "name": "@iota/audit-trails", + "name": "@iota/audit-trail", "author": "IOTA Foundation ", - "description": "WASM bindings for IOTA Audit Trails. To be used in JavaScript/TypeScript.", + "description": "WASM bindings for IOTA Audit Trail. To be used in JavaScript/TypeScript.", "homepage": "https://www.iota.org", "version": "0.1.0-alpha", "license": "Apache-2.0", @@ -16,13 +16,13 @@ "build:src": "cargo build --lib --release --target wasm32-unknown-unknown --target-dir ../target", "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/audit_trails_wasm.wasm --typescript --weak-refs --target nodejs --out-dir node && node ../build/node audit_trails_wasm && tsc --project ./lib/tsconfig.json && node ../build/replace_paths ./lib/tsconfig.json node audit_trails_wasm", + "bundle:nodejs": "wasm-bindgen ../target/wasm32-unknown-unknown/release/audit_trail_wasm.wasm --typescript --weak-refs --target nodejs --out-dir node && node ../build/node audit_trail_wasm && tsc --project ./lib/tsconfig.json && node ../build/replace_paths ./lib/tsconfig.json node audit_trail_wasm", "prebundle:web": "rimraf web", - "bundle:web": "wasm-bindgen ../target/wasm32-unknown-unknown/release/audit_trails_wasm.wasm --typescript --target web --out-dir web && node ../build/web audit_trails_wasm && tsc --project ./lib/tsconfig.web.json && node ../build/replace_paths ./lib/tsconfig.web.json web audit_trails_wasm", - "build:nodejs": "npm run build:src:nodejs && npm run bundle:nodejs && wasm-opt -O node/audit_trails_wasm_bg.wasm -o node/audit_trails_wasm_bg.wasm", - "build:web": "npm run build:src && npm run bundle:web && wasm-opt -O web/audit_trails_wasm_bg.wasm -o web/audit_trails_wasm_bg.wasm", + "bundle:web": "wasm-bindgen ../target/wasm32-unknown-unknown/release/audit_trail_wasm.wasm --typescript --target web --out-dir web && node ../build/web audit_trail_wasm && tsc --project ./lib/tsconfig.web.json && node ../build/replace_paths ./lib/tsconfig.web.json web audit_trail_wasm", + "build:nodejs": "npm run build:src:nodejs && npm run bundle:nodejs && wasm-opt -O node/audit_trail_wasm_bg.wasm -o node/audit_trail_wasm_bg.wasm", + "build:web": "npm run build:src && npm run bundle:web && wasm-opt -O web/audit_trail_wasm_bg.wasm -o web/audit_trail_wasm_bg.wasm", "build:docs": "typedoc && npm run fix_docs", - "build:examples:web": "tsc --project ./examples/tsconfig.web.json || node ../build/replace_paths ./examples/tsconfig.web.json dist/web audit_trails_wasm/examples resolve", + "build:examples:web": "tsc --project ./examples/tsconfig.web.json || node ../build/replace_paths ./examples/tsconfig.web.json dist/web audit_trail_wasm/examples resolve", "build": "npm run build:web && npm run build:nodejs && npm run build:docs", "example:node": "ts-node --project ./examples/tsconfig.node.json -r tsconfig-paths/register ./examples/src/main.ts", "test": "npm run test:node", diff --git a/bindings/wasm/audit_trails_wasm/src/builder.rs b/bindings/wasm/audit_trails_wasm/src/builder.rs index 0350f09a..c79b18f8 100644 --- a/bindings/wasm/audit_trails_wasm/src/builder.rs +++ b/bindings/wasm/audit_trails_wasm/src/builder.rs @@ -1,7 +1,7 @@ // Copyright 2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use audit_trails::core::builder::AuditTrailBuilder; +use audit_trail::core::builder::AuditTrailBuilder; use iota_interaction_ts::wasm_error::Result; use product_common::bindings::transaction::WasmTransactionBuilder; use product_common::bindings::utils::{into_transaction_builder, parse_wasm_iota_address}; diff --git a/bindings/wasm/audit_trails_wasm/src/client.rs b/bindings/wasm/audit_trails_wasm/src/client.rs index 4d5001ed..a09a5ea1 100644 --- a/bindings/wasm/audit_trails_wasm/src/client.rs +++ b/bindings/wasm/audit_trails_wasm/src/client.rs @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 use anyhow::anyhow; -use audit_trails::{AuditTrailClient, AuditTrailClientReadOnly, PackageOverrides}; +use audit_trail::{AuditTrailClient, AuditTrailClientReadOnly, PackageOverrides}; use iota_interaction_ts::bindings::{WasmIotaClient, WasmPublicKey, WasmTransactionSigner}; use iota_interaction_ts::wasm_error::{wasm_error, Result, WasmResult}; use product_common::bindings::utils::parse_wasm_object_id; diff --git a/bindings/wasm/audit_trails_wasm/src/client_read_only.rs b/bindings/wasm/audit_trails_wasm/src/client_read_only.rs index 7ede06be..aaa8a716 100644 --- a/bindings/wasm/audit_trails_wasm/src/client_read_only.rs +++ b/bindings/wasm/audit_trails_wasm/src/client_read_only.rs @@ -1,7 +1,7 @@ // Copyright 2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use audit_trails::{AuditTrailClientReadOnly, PackageOverrides}; +use audit_trail::{AuditTrailClientReadOnly, PackageOverrides}; use iota_interaction_ts::bindings::WasmIotaClient; use iota_interaction_ts::wasm_error::{Result, WasmResult}; use product_common::bindings::utils::parse_wasm_object_id; diff --git a/bindings/wasm/audit_trails_wasm/src/trail.rs b/bindings/wasm/audit_trails_wasm/src/trail.rs index 62e86b3e..3b63d874 100644 --- a/bindings/wasm/audit_trails_wasm/src/trail.rs +++ b/bindings/wasm/audit_trails_wasm/src/trail.rs @@ -1,18 +1,18 @@ // Copyright 2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use audit_trails::core::access::{ +use audit_trail::core::access::{ CleanupRevokedCapabilities, CreateRole, DeleteRole, DestroyCapability, DestroyInitialAdminCapability, IssueCapability, RevokeCapability, RevokeInitialAdminCapability, UpdateRole, }; -use audit_trails::core::create::{CreateTrail, TrailCreated}; -use audit_trails::core::locking::{ +use audit_trail::core::create::{CreateTrail, TrailCreated}; +use audit_trail::core::locking::{ UpdateDeleteRecordWindow, UpdateDeleteTrailLock, UpdateLockingConfig, UpdateWriteLock, }; -use audit_trails::core::records::{AddRecord, DeleteRecord, DeleteRecordsBatch}; -use audit_trails::core::tags::{AddRecordTag, RemoveRecordTag}; -use audit_trails::core::trail::{DeleteAuditTrail, Migrate, UpdateMetadata}; -use audit_trails::core::types::{ +use audit_trail::core::records::{AddRecord, DeleteRecord, DeleteRecordsBatch}; +use audit_trail::core::tags::{AddRecordTag, RemoveRecordTag}; +use audit_trail::core::trail::{DeleteAuditTrail, Migrate, UpdateMetadata}; +use audit_trail::core::types::{ AuditTrailDeleted, CapabilityDestroyed, CapabilityIssued, CapabilityRevoked, OnChainAuditTrail, RecordAdded, RecordDeleted, RoleCreated, RoleDeleted, RoleUpdated, }; diff --git a/bindings/wasm/audit_trails_wasm/src/trail_handle/access.rs b/bindings/wasm/audit_trails_wasm/src/trail_handle/access.rs index c97a0bf2..6b5d1862 100644 --- a/bindings/wasm/audit_trails_wasm/src/trail_handle/access.rs +++ b/bindings/wasm/audit_trails_wasm/src/trail_handle/access.rs @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 use anyhow::anyhow; -use audit_trails::AuditTrailClient; +use audit_trail::AuditTrailClient; use iota_interaction::types::base_types::ObjectID; use iota_interaction_ts::bindings::WasmTransactionSigner; use iota_interaction_ts::wasm_error::{wasm_error, Result}; diff --git a/bindings/wasm/audit_trails_wasm/src/trail_handle/locking.rs b/bindings/wasm/audit_trails_wasm/src/trail_handle/locking.rs index c82fef9a..0ed6fb9e 100644 --- a/bindings/wasm/audit_trails_wasm/src/trail_handle/locking.rs +++ b/bindings/wasm/audit_trails_wasm/src/trail_handle/locking.rs @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 use anyhow::anyhow; -use audit_trails::{AuditTrailClient, AuditTrailClientReadOnly}; +use audit_trail::{AuditTrailClient, AuditTrailClientReadOnly}; use iota_interaction::types::base_types::ObjectID; use iota_interaction_ts::bindings::WasmTransactionSigner; use iota_interaction_ts::wasm_error::{wasm_error, Result, WasmResult}; diff --git a/bindings/wasm/audit_trails_wasm/src/trail_handle/mod.rs b/bindings/wasm/audit_trails_wasm/src/trail_handle/mod.rs index e3a7080c..2711433b 100644 --- a/bindings/wasm/audit_trails_wasm/src/trail_handle/mod.rs +++ b/bindings/wasm/audit_trails_wasm/src/trail_handle/mod.rs @@ -8,7 +8,7 @@ mod tags; pub(crate) use access::WasmTrailAccess; use anyhow::anyhow; -use audit_trails::{AuditTrailClient, AuditTrailClientReadOnly}; +use audit_trail::{AuditTrailClient, AuditTrailClientReadOnly}; use iota_interaction::types::base_types::ObjectID; use iota_interaction_ts::bindings::WasmTransactionSigner; use iota_interaction_ts::wasm_error::{wasm_error, Result, WasmResult}; diff --git a/bindings/wasm/audit_trails_wasm/src/trail_handle/records.rs b/bindings/wasm/audit_trails_wasm/src/trail_handle/records.rs index ada7e32c..68456e5e 100644 --- a/bindings/wasm/audit_trails_wasm/src/trail_handle/records.rs +++ b/bindings/wasm/audit_trails_wasm/src/trail_handle/records.rs @@ -2,8 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 use anyhow::anyhow; -use audit_trails::core::types::Data as AuditTrailData; -use audit_trails::{AuditTrailClient, AuditTrailClientReadOnly}; +use audit_trail::core::types::Data as AuditTrailData; +use audit_trail::{AuditTrailClient, AuditTrailClientReadOnly}; use iota_interaction::types::base_types::ObjectID; use iota_interaction_ts::bindings::WasmTransactionSigner; use iota_interaction_ts::wasm_error::{wasm_error, Result, WasmResult}; diff --git a/bindings/wasm/audit_trails_wasm/src/trail_handle/tags.rs b/bindings/wasm/audit_trails_wasm/src/trail_handle/tags.rs index a6fbc4be..9e0e3766 100644 --- a/bindings/wasm/audit_trails_wasm/src/trail_handle/tags.rs +++ b/bindings/wasm/audit_trails_wasm/src/trail_handle/tags.rs @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 use anyhow::anyhow; -use audit_trails::AuditTrailClient; +use audit_trail::AuditTrailClient; use iota_interaction::types::base_types::ObjectID; use iota_interaction_ts::bindings::WasmTransactionSigner; use iota_interaction_ts::wasm_error::{wasm_error, Result}; diff --git a/bindings/wasm/audit_trails_wasm/src/types.rs b/bindings/wasm/audit_trails_wasm/src/types.rs index 0aaefdbc..da65fd76 100644 --- a/bindings/wasm/audit_trails_wasm/src/types.rs +++ b/bindings/wasm/audit_trails_wasm/src/types.rs @@ -3,7 +3,7 @@ use std::collections::{HashMap, HashSet}; -use audit_trails::core::types::{ +use audit_trail::core::types::{ AuditTrailCreated, AuditTrailDeleted, Capability, CapabilityAdminPermissions, CapabilityDestroyed, CapabilityIssueOptions, CapabilityIssued, CapabilityRevoked, Data, ImmutableMetadata, LockingConfig, LockingWindow, PaginatedRecord, Permission, PermissionSet, Record, RecordAdded, RecordCorrection, RecordDeleted, Role, diff --git a/bindings/wasm/audit_trails_wasm/tsconfig.json b/bindings/wasm/audit_trails_wasm/tsconfig.json index a741de44..6b1fd874 100644 --- a/bindings/wasm/audit_trails_wasm/tsconfig.json +++ b/bindings/wasm/audit_trails_wasm/tsconfig.json @@ -3,7 +3,7 @@ "compilerOptions": { "baseUrl": ".", "paths": { - "@iota/audit-trails/*": [ + "@iota/audit-trail/*": [ "./*" ] } diff --git a/bindings/wasm/audit_trails_wasm/typedoc.json b/bindings/wasm/audit_trails_wasm/typedoc.json index 57f0ba99..c28f411b 100644 --- a/bindings/wasm/audit_trails_wasm/typedoc.json +++ b/bindings/wasm/audit_trails_wasm/typedoc.json @@ -1,5 +1,5 @@ { - "name": "@iota/audit-trails API documentation", + "name": "@iota/audit-trail API documentation", "extends": [ "../typedoc.json" ], From 72b9698be94ccce8a88a5d972a6f164ef815e8b8 Mon Sep 17 00:00:00 2001 From: chrisgitiota Date: Mon, 30 Mar 2026 14:28:33 +0200 Subject: [PATCH 110/189] Rename `audit_trails_wasm` folder to `audit_trail_wasm` --- bindings/wasm/{audit_trails_wasm => audit_trail_wasm}/Cargo.toml | 0 bindings/wasm/{audit_trails_wasm => audit_trail_wasm}/README.md | 0 .../{audit_trails_wasm => audit_trail_wasm}/docs/wasm/api_ref.md | 0 .../docs/wasm/audit_trails_wasm/api_ref.md | 0 .../docs/wasm/audit_trails_wasm/classes/DefaultHttpClient.md | 0 .../{audit_trails_wasm => audit_trail_wasm}/examples/README.md | 0 .../examples/src/01_create_trail.ts | 0 .../examples/src/02_fetch_trail.ts | 0 .../examples/src/03_add_and_list_records.ts | 0 .../examples/src/04_delete_records_batch.ts | 0 .../{audit_trails_wasm => audit_trail_wasm}/examples/src/main.ts | 0 .../{audit_trails_wasm => audit_trail_wasm}/examples/src/tests.ts | 0 .../{audit_trails_wasm => audit_trail_wasm}/examples/src/util.ts | 0 .../examples/src/web-main.ts | 0 .../examples/tsconfig.node.json | 0 .../examples/tsconfig.web.json | 0 .../wasm/{audit_trails_wasm => audit_trail_wasm}/lib/index.ts | 0 .../{audit_trails_wasm => audit_trail_wasm}/lib/tsconfig.json | 0 .../{audit_trails_wasm => audit_trail_wasm}/lib/tsconfig.web.json | 0 .../{audit_trails_wasm => audit_trail_wasm}/package-lock.json | 0 .../wasm/{audit_trails_wasm => audit_trail_wasm}/package.json | 0 .../{audit_trails_wasm => audit_trail_wasm}/rust-toolchain.toml | 0 .../wasm/{audit_trails_wasm => audit_trail_wasm}/src/builder.rs | 0 .../wasm/{audit_trails_wasm => audit_trail_wasm}/src/client.rs | 0 .../src/client_read_only.rs | 0 bindings/wasm/{audit_trails_wasm => audit_trail_wasm}/src/lib.rs | 0 .../wasm/{audit_trails_wasm => audit_trail_wasm}/src/trail.rs | 0 .../src/trail_handle/access.rs | 0 .../src/trail_handle/locking.rs | 0 .../src/trail_handle/mod.rs | 0 .../src/trail_handle/records.rs | 0 .../src/trail_handle/tags.rs | 0 .../wasm/{audit_trails_wasm => audit_trail_wasm}/src/types.rs | 0 .../wasm/{audit_trails_wasm => audit_trail_wasm}/tsconfig.json | 0 .../{audit_trails_wasm => audit_trail_wasm}/tsconfig.node.json | 0 .../{audit_trails_wasm => audit_trail_wasm}/tsconfig.typedoc.json | 0 .../wasm/{audit_trails_wasm => audit_trail_wasm}/typedoc.json | 0 37 files changed, 0 insertions(+), 0 deletions(-) rename bindings/wasm/{audit_trails_wasm => audit_trail_wasm}/Cargo.toml (100%) rename bindings/wasm/{audit_trails_wasm => audit_trail_wasm}/README.md (100%) rename bindings/wasm/{audit_trails_wasm => audit_trail_wasm}/docs/wasm/api_ref.md (100%) rename bindings/wasm/{audit_trails_wasm => audit_trail_wasm}/docs/wasm/audit_trails_wasm/api_ref.md (100%) rename bindings/wasm/{audit_trails_wasm => audit_trail_wasm}/docs/wasm/audit_trails_wasm/classes/DefaultHttpClient.md (100%) rename bindings/wasm/{audit_trails_wasm => audit_trail_wasm}/examples/README.md (100%) rename bindings/wasm/{audit_trails_wasm => audit_trail_wasm}/examples/src/01_create_trail.ts (100%) rename bindings/wasm/{audit_trails_wasm => audit_trail_wasm}/examples/src/02_fetch_trail.ts (100%) rename bindings/wasm/{audit_trails_wasm => audit_trail_wasm}/examples/src/03_add_and_list_records.ts (100%) rename bindings/wasm/{audit_trails_wasm => audit_trail_wasm}/examples/src/04_delete_records_batch.ts (100%) rename bindings/wasm/{audit_trails_wasm => audit_trail_wasm}/examples/src/main.ts (100%) rename bindings/wasm/{audit_trails_wasm => audit_trail_wasm}/examples/src/tests.ts (100%) rename bindings/wasm/{audit_trails_wasm => audit_trail_wasm}/examples/src/util.ts (100%) rename bindings/wasm/{audit_trails_wasm => audit_trail_wasm}/examples/src/web-main.ts (100%) rename bindings/wasm/{audit_trails_wasm => audit_trail_wasm}/examples/tsconfig.node.json (100%) rename bindings/wasm/{audit_trails_wasm => audit_trail_wasm}/examples/tsconfig.web.json (100%) rename bindings/wasm/{audit_trails_wasm => audit_trail_wasm}/lib/index.ts (100%) rename bindings/wasm/{audit_trails_wasm => audit_trail_wasm}/lib/tsconfig.json (100%) rename bindings/wasm/{audit_trails_wasm => audit_trail_wasm}/lib/tsconfig.web.json (100%) rename bindings/wasm/{audit_trails_wasm => audit_trail_wasm}/package-lock.json (100%) rename bindings/wasm/{audit_trails_wasm => audit_trail_wasm}/package.json (100%) rename bindings/wasm/{audit_trails_wasm => audit_trail_wasm}/rust-toolchain.toml (100%) rename bindings/wasm/{audit_trails_wasm => audit_trail_wasm}/src/builder.rs (100%) rename bindings/wasm/{audit_trails_wasm => audit_trail_wasm}/src/client.rs (100%) rename bindings/wasm/{audit_trails_wasm => audit_trail_wasm}/src/client_read_only.rs (100%) rename bindings/wasm/{audit_trails_wasm => audit_trail_wasm}/src/lib.rs (100%) rename bindings/wasm/{audit_trails_wasm => audit_trail_wasm}/src/trail.rs (100%) rename bindings/wasm/{audit_trails_wasm => audit_trail_wasm}/src/trail_handle/access.rs (100%) rename bindings/wasm/{audit_trails_wasm => audit_trail_wasm}/src/trail_handle/locking.rs (100%) rename bindings/wasm/{audit_trails_wasm => audit_trail_wasm}/src/trail_handle/mod.rs (100%) rename bindings/wasm/{audit_trails_wasm => audit_trail_wasm}/src/trail_handle/records.rs (100%) rename bindings/wasm/{audit_trails_wasm => audit_trail_wasm}/src/trail_handle/tags.rs (100%) rename bindings/wasm/{audit_trails_wasm => audit_trail_wasm}/src/types.rs (100%) rename bindings/wasm/{audit_trails_wasm => audit_trail_wasm}/tsconfig.json (100%) rename bindings/wasm/{audit_trails_wasm => audit_trail_wasm}/tsconfig.node.json (100%) rename bindings/wasm/{audit_trails_wasm => audit_trail_wasm}/tsconfig.typedoc.json (100%) rename bindings/wasm/{audit_trails_wasm => audit_trail_wasm}/typedoc.json (100%) diff --git a/bindings/wasm/audit_trails_wasm/Cargo.toml b/bindings/wasm/audit_trail_wasm/Cargo.toml similarity index 100% rename from bindings/wasm/audit_trails_wasm/Cargo.toml rename to bindings/wasm/audit_trail_wasm/Cargo.toml diff --git a/bindings/wasm/audit_trails_wasm/README.md b/bindings/wasm/audit_trail_wasm/README.md similarity index 100% rename from bindings/wasm/audit_trails_wasm/README.md rename to bindings/wasm/audit_trail_wasm/README.md diff --git a/bindings/wasm/audit_trails_wasm/docs/wasm/api_ref.md b/bindings/wasm/audit_trail_wasm/docs/wasm/api_ref.md similarity index 100% rename from bindings/wasm/audit_trails_wasm/docs/wasm/api_ref.md rename to bindings/wasm/audit_trail_wasm/docs/wasm/api_ref.md diff --git a/bindings/wasm/audit_trails_wasm/docs/wasm/audit_trails_wasm/api_ref.md b/bindings/wasm/audit_trail_wasm/docs/wasm/audit_trails_wasm/api_ref.md similarity index 100% rename from bindings/wasm/audit_trails_wasm/docs/wasm/audit_trails_wasm/api_ref.md rename to bindings/wasm/audit_trail_wasm/docs/wasm/audit_trails_wasm/api_ref.md diff --git a/bindings/wasm/audit_trails_wasm/docs/wasm/audit_trails_wasm/classes/DefaultHttpClient.md b/bindings/wasm/audit_trail_wasm/docs/wasm/audit_trails_wasm/classes/DefaultHttpClient.md similarity index 100% rename from bindings/wasm/audit_trails_wasm/docs/wasm/audit_trails_wasm/classes/DefaultHttpClient.md rename to bindings/wasm/audit_trail_wasm/docs/wasm/audit_trails_wasm/classes/DefaultHttpClient.md diff --git a/bindings/wasm/audit_trails_wasm/examples/README.md b/bindings/wasm/audit_trail_wasm/examples/README.md similarity index 100% rename from bindings/wasm/audit_trails_wasm/examples/README.md rename to bindings/wasm/audit_trail_wasm/examples/README.md diff --git a/bindings/wasm/audit_trails_wasm/examples/src/01_create_trail.ts b/bindings/wasm/audit_trail_wasm/examples/src/01_create_trail.ts similarity index 100% rename from bindings/wasm/audit_trails_wasm/examples/src/01_create_trail.ts rename to bindings/wasm/audit_trail_wasm/examples/src/01_create_trail.ts diff --git a/bindings/wasm/audit_trails_wasm/examples/src/02_fetch_trail.ts b/bindings/wasm/audit_trail_wasm/examples/src/02_fetch_trail.ts similarity index 100% rename from bindings/wasm/audit_trails_wasm/examples/src/02_fetch_trail.ts rename to bindings/wasm/audit_trail_wasm/examples/src/02_fetch_trail.ts diff --git a/bindings/wasm/audit_trails_wasm/examples/src/03_add_and_list_records.ts b/bindings/wasm/audit_trail_wasm/examples/src/03_add_and_list_records.ts similarity index 100% rename from bindings/wasm/audit_trails_wasm/examples/src/03_add_and_list_records.ts rename to bindings/wasm/audit_trail_wasm/examples/src/03_add_and_list_records.ts diff --git a/bindings/wasm/audit_trails_wasm/examples/src/04_delete_records_batch.ts b/bindings/wasm/audit_trail_wasm/examples/src/04_delete_records_batch.ts similarity index 100% rename from bindings/wasm/audit_trails_wasm/examples/src/04_delete_records_batch.ts rename to bindings/wasm/audit_trail_wasm/examples/src/04_delete_records_batch.ts diff --git a/bindings/wasm/audit_trails_wasm/examples/src/main.ts b/bindings/wasm/audit_trail_wasm/examples/src/main.ts similarity index 100% rename from bindings/wasm/audit_trails_wasm/examples/src/main.ts rename to bindings/wasm/audit_trail_wasm/examples/src/main.ts diff --git a/bindings/wasm/audit_trails_wasm/examples/src/tests.ts b/bindings/wasm/audit_trail_wasm/examples/src/tests.ts similarity index 100% rename from bindings/wasm/audit_trails_wasm/examples/src/tests.ts rename to bindings/wasm/audit_trail_wasm/examples/src/tests.ts diff --git a/bindings/wasm/audit_trails_wasm/examples/src/util.ts b/bindings/wasm/audit_trail_wasm/examples/src/util.ts similarity index 100% rename from bindings/wasm/audit_trails_wasm/examples/src/util.ts rename to bindings/wasm/audit_trail_wasm/examples/src/util.ts diff --git a/bindings/wasm/audit_trails_wasm/examples/src/web-main.ts b/bindings/wasm/audit_trail_wasm/examples/src/web-main.ts similarity index 100% rename from bindings/wasm/audit_trails_wasm/examples/src/web-main.ts rename to bindings/wasm/audit_trail_wasm/examples/src/web-main.ts diff --git a/bindings/wasm/audit_trails_wasm/examples/tsconfig.node.json b/bindings/wasm/audit_trail_wasm/examples/tsconfig.node.json similarity index 100% rename from bindings/wasm/audit_trails_wasm/examples/tsconfig.node.json rename to bindings/wasm/audit_trail_wasm/examples/tsconfig.node.json diff --git a/bindings/wasm/audit_trails_wasm/examples/tsconfig.web.json b/bindings/wasm/audit_trail_wasm/examples/tsconfig.web.json similarity index 100% rename from bindings/wasm/audit_trails_wasm/examples/tsconfig.web.json rename to bindings/wasm/audit_trail_wasm/examples/tsconfig.web.json diff --git a/bindings/wasm/audit_trails_wasm/lib/index.ts b/bindings/wasm/audit_trail_wasm/lib/index.ts similarity index 100% rename from bindings/wasm/audit_trails_wasm/lib/index.ts rename to bindings/wasm/audit_trail_wasm/lib/index.ts diff --git a/bindings/wasm/audit_trails_wasm/lib/tsconfig.json b/bindings/wasm/audit_trail_wasm/lib/tsconfig.json similarity index 100% rename from bindings/wasm/audit_trails_wasm/lib/tsconfig.json rename to bindings/wasm/audit_trail_wasm/lib/tsconfig.json diff --git a/bindings/wasm/audit_trails_wasm/lib/tsconfig.web.json b/bindings/wasm/audit_trail_wasm/lib/tsconfig.web.json similarity index 100% rename from bindings/wasm/audit_trails_wasm/lib/tsconfig.web.json rename to bindings/wasm/audit_trail_wasm/lib/tsconfig.web.json diff --git a/bindings/wasm/audit_trails_wasm/package-lock.json b/bindings/wasm/audit_trail_wasm/package-lock.json similarity index 100% rename from bindings/wasm/audit_trails_wasm/package-lock.json rename to bindings/wasm/audit_trail_wasm/package-lock.json diff --git a/bindings/wasm/audit_trails_wasm/package.json b/bindings/wasm/audit_trail_wasm/package.json similarity index 100% rename from bindings/wasm/audit_trails_wasm/package.json rename to bindings/wasm/audit_trail_wasm/package.json diff --git a/bindings/wasm/audit_trails_wasm/rust-toolchain.toml b/bindings/wasm/audit_trail_wasm/rust-toolchain.toml similarity index 100% rename from bindings/wasm/audit_trails_wasm/rust-toolchain.toml rename to bindings/wasm/audit_trail_wasm/rust-toolchain.toml diff --git a/bindings/wasm/audit_trails_wasm/src/builder.rs b/bindings/wasm/audit_trail_wasm/src/builder.rs similarity index 100% rename from bindings/wasm/audit_trails_wasm/src/builder.rs rename to bindings/wasm/audit_trail_wasm/src/builder.rs diff --git a/bindings/wasm/audit_trails_wasm/src/client.rs b/bindings/wasm/audit_trail_wasm/src/client.rs similarity index 100% rename from bindings/wasm/audit_trails_wasm/src/client.rs rename to bindings/wasm/audit_trail_wasm/src/client.rs diff --git a/bindings/wasm/audit_trails_wasm/src/client_read_only.rs b/bindings/wasm/audit_trail_wasm/src/client_read_only.rs similarity index 100% rename from bindings/wasm/audit_trails_wasm/src/client_read_only.rs rename to bindings/wasm/audit_trail_wasm/src/client_read_only.rs diff --git a/bindings/wasm/audit_trails_wasm/src/lib.rs b/bindings/wasm/audit_trail_wasm/src/lib.rs similarity index 100% rename from bindings/wasm/audit_trails_wasm/src/lib.rs rename to bindings/wasm/audit_trail_wasm/src/lib.rs diff --git a/bindings/wasm/audit_trails_wasm/src/trail.rs b/bindings/wasm/audit_trail_wasm/src/trail.rs similarity index 100% rename from bindings/wasm/audit_trails_wasm/src/trail.rs rename to bindings/wasm/audit_trail_wasm/src/trail.rs diff --git a/bindings/wasm/audit_trails_wasm/src/trail_handle/access.rs b/bindings/wasm/audit_trail_wasm/src/trail_handle/access.rs similarity index 100% rename from bindings/wasm/audit_trails_wasm/src/trail_handle/access.rs rename to bindings/wasm/audit_trail_wasm/src/trail_handle/access.rs diff --git a/bindings/wasm/audit_trails_wasm/src/trail_handle/locking.rs b/bindings/wasm/audit_trail_wasm/src/trail_handle/locking.rs similarity index 100% rename from bindings/wasm/audit_trails_wasm/src/trail_handle/locking.rs rename to bindings/wasm/audit_trail_wasm/src/trail_handle/locking.rs diff --git a/bindings/wasm/audit_trails_wasm/src/trail_handle/mod.rs b/bindings/wasm/audit_trail_wasm/src/trail_handle/mod.rs similarity index 100% rename from bindings/wasm/audit_trails_wasm/src/trail_handle/mod.rs rename to bindings/wasm/audit_trail_wasm/src/trail_handle/mod.rs diff --git a/bindings/wasm/audit_trails_wasm/src/trail_handle/records.rs b/bindings/wasm/audit_trail_wasm/src/trail_handle/records.rs similarity index 100% rename from bindings/wasm/audit_trails_wasm/src/trail_handle/records.rs rename to bindings/wasm/audit_trail_wasm/src/trail_handle/records.rs diff --git a/bindings/wasm/audit_trails_wasm/src/trail_handle/tags.rs b/bindings/wasm/audit_trail_wasm/src/trail_handle/tags.rs similarity index 100% rename from bindings/wasm/audit_trails_wasm/src/trail_handle/tags.rs rename to bindings/wasm/audit_trail_wasm/src/trail_handle/tags.rs diff --git a/bindings/wasm/audit_trails_wasm/src/types.rs b/bindings/wasm/audit_trail_wasm/src/types.rs similarity index 100% rename from bindings/wasm/audit_trails_wasm/src/types.rs rename to bindings/wasm/audit_trail_wasm/src/types.rs diff --git a/bindings/wasm/audit_trails_wasm/tsconfig.json b/bindings/wasm/audit_trail_wasm/tsconfig.json similarity index 100% rename from bindings/wasm/audit_trails_wasm/tsconfig.json rename to bindings/wasm/audit_trail_wasm/tsconfig.json diff --git a/bindings/wasm/audit_trails_wasm/tsconfig.node.json b/bindings/wasm/audit_trail_wasm/tsconfig.node.json similarity index 100% rename from bindings/wasm/audit_trails_wasm/tsconfig.node.json rename to bindings/wasm/audit_trail_wasm/tsconfig.node.json diff --git a/bindings/wasm/audit_trails_wasm/tsconfig.typedoc.json b/bindings/wasm/audit_trail_wasm/tsconfig.typedoc.json similarity index 100% rename from bindings/wasm/audit_trails_wasm/tsconfig.typedoc.json rename to bindings/wasm/audit_trail_wasm/tsconfig.typedoc.json diff --git a/bindings/wasm/audit_trails_wasm/typedoc.json b/bindings/wasm/audit_trail_wasm/typedoc.json similarity index 100% rename from bindings/wasm/audit_trails_wasm/typedoc.json rename to bindings/wasm/audit_trail_wasm/typedoc.json From 2b19f1127b9ef86233f8cad78ffe7486e2adda2d Mon Sep 17 00:00:00 2001 From: chrisgitiota Date: Mon, 30 Mar 2026 14:34:26 +0200 Subject: [PATCH 111/189] Rename audit trail move package from `audit_trail`to `IotaAuditTrail` --- audit-trail-move/Move.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/audit-trail-move/Move.toml b/audit-trail-move/Move.toml index c83e2eed..d23a7b38 100644 --- a/audit-trail-move/Move.toml +++ b/audit-trail-move/Move.toml @@ -1,5 +1,5 @@ [package] -name = "audit_trail" +name = "IotaAuditTrail" edition = "2024.beta" [dependencies] From c2238601c4cb1b26c5eb6a3418e7af541f86fd71 Mon Sep 17 00:00:00 2001 From: Yasir Date: Mon, 30 Mar 2026 16:51:58 +0300 Subject: [PATCH 112/189] chore: deploy audit trail to testnet --- audit-trail-move/Move.history.json | 10 ++++++++-- audit-trail-move/Move.lock | 8 +++++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/audit-trail-move/Move.history.json b/audit-trail-move/Move.history.json index d5ba8b0c..04e94921 100644 --- a/audit-trail-move/Move.history.json +++ b/audit-trail-move/Move.history.json @@ -1,4 +1,10 @@ { - "aliases": {}, - "envs": {} + "aliases": { + "testnet": "2304aa97" + }, + "envs": { + "2304aa97": [ + "0x7655d346145e2ba7fcb6a5c63b4b9ec18a92c435364206e5c3f3dfd8cb95d98d" + ] + } } \ No newline at end of file diff --git a/audit-trail-move/Move.lock b/audit-trail-move/Move.lock index d8d3b616..e40c12e9 100644 --- a/audit-trail-move/Move.lock +++ b/audit-trail-move/Move.lock @@ -2,7 +2,7 @@ [move] version = 3 -manifest_digest = "A98478D66EC9631ABE28DCBEBAD8D608F813CA5A3D0856E6282E31F4FE7B20FF" +manifest_digest = "0CE297EF7E5DDA3F07E2E03FFCE0F19FA61914E440324D82CBC4E431B1FD600D" deps_digest = "397E6A9F7A624706DBDFEE056CE88391A15876868FD18A88504DA74EB458D697" dependencies = [ { id = "Iota", name = "Iota" }, @@ -65,3 +65,9 @@ chain-id = "39f6312e" original-published-id = "0x148830e6d46618708fcc5065ff2ce1077fe45997cd0bae3f489c9b1ee5571f48" latest-published-id = "0x148830e6d46618708fcc5065ff2ce1077fe45997cd0bae3f489c9b1ee5571f48" published-version = "1" + +[env.testnet] +chain-id = "2304aa97" +original-published-id = "0x7655d346145e2ba7fcb6a5c63b4b9ec18a92c435364206e5c3f3dfd8cb95d98d" +latest-published-id = "0x7655d346145e2ba7fcb6a5c63b4b9ec18a92c435364206e5c3f3dfd8cb95d98d" +published-version = "1" From 9ae6af5d108b3b93f57e2d9d82d6ad0c31f37a5e Mon Sep 17 00:00:00 2001 From: Yasir Date: Mon, 30 Mar 2026 17:15:44 +0300 Subject: [PATCH 113/189] chore: use git instead of local dep --- audit-trail-move/Move.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/audit-trail-move/Move.toml b/audit-trail-move/Move.toml index d23a7b38..9dfad22c 100644 --- a/audit-trail-move/Move.toml +++ b/audit-trail-move/Move.toml @@ -3,7 +3,7 @@ name = "IotaAuditTrail" edition = "2024.beta" [dependencies] -TfComponents = { local = "../../product-core/components_move" } +TfComponents = { git = "https://github.com/iotaledger/product-core.git", subdir = "components_move", rev = "feat/tf-compoenents-dev" } [addresses] audit_trail = "0x0" From 4e47496f78c7d726311e2b81dca4fd87f31df437 Mon Sep 17 00:00:00 2001 From: Yasir Date: Tue, 31 Mar 2026 13:11:24 +0300 Subject: [PATCH 114/189] fix audit trail initial tag usage accounting --- audit-trail-move/sources/audit_trail.move | 9 +- .../tests/create_audit_trail_tests.move | 101 ++++++++++++++++++ 2 files changed, 108 insertions(+), 2 deletions(-) diff --git a/audit-trail-move/sources/audit_trail.move b/audit-trail-move/sources/audit_trail.move index 89d1b516..dcde0b06 100644 --- a/audit-trail-move/sources/audit_trail.move +++ b/audit-trail-move/sources/audit_trail.move @@ -165,6 +165,7 @@ public fun create( let trail_uid = object::new(ctx); let trail_id = object::uid_to_inner(&trail_uid); + let mut tags = record_tags::new_tag_registry(tags); let mut records = linked_table::new>(ctx); let mut sequence_number = 0; @@ -177,6 +178,12 @@ public fun create( timestamp, ); + if (record::tag(&record).is_some()) { + let initial_tag = option::borrow(record::tag(&record)); + assert!(record_tags::contains(&tags, initial_tag), ERecordTagNotDefined); + record_tags::increment_usage_count(&mut tags, initial_tag); + }; + linked_table::push_back(&mut records, 0, record); sequence_number = 1; } else { @@ -203,8 +210,6 @@ public fun create( ctx, ); - let tags = record_tags::new_tag_registry(tags); - let trail = AuditTrail { id: trail_uid, creator, diff --git a/audit-trail-move/tests/create_audit_trail_tests.move b/audit-trail-move/tests/create_audit_trail_tests.move index 29a727d2..8f6f6c52 100644 --- a/audit-trail-move/tests/create_audit_trail_tests.move +++ b/audit-trail-move/tests/create_audit_trail_tests.move @@ -185,6 +185,107 @@ fun test_create_with_initial_record() { ts::end(scenario); } +#[test] +fun test_create_with_tagged_initial_record_tracks_tag_usage() { + let user = @0xC; + let mut scenario = ts::begin(user); + + { + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + clock.set_for_testing(initial_time_for_testing()); + + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + let initial_record = record::new_initial_record( + record::new_text(string::utf8(b"Tagged initial record")), + option::none(), + option::some(string::utf8(b"finance")), + ); + + let (admin_cap, trail_id) = main::create( + option::some(initial_record), + locking_config, + option::none(), + option::none(), + vector[string::utf8(b"finance")], + &clock, + ts::ctx(&mut scenario), + ); + + assert!(admin_cap.role() == initial_admin_role_name(), 0); + assert!(admin_cap.target_key() == trail_id, 1); + admin_cap.destroy_for_testing(); + clock::destroy_for_testing(clock); + }; + + ts::next_tx(&mut scenario, user); + { + let trail = ts::take_shared>(&scenario); + let finance_tag = string::utf8(b"finance"); + + assert!(trail.record_count() == 1, 2); + assert!(trail.tags().usage_count(&finance_tag) == option::some(1), 3); + assert!(trail.tags().is_in_use(&finance_tag), 4); + + ts::return_shared(trail); + }; + + ts::end(scenario); +} + +#[test, expected_failure(abort_code = audit_trail::main::ERecordTagInUse)] +fun test_create_with_tagged_initial_record_blocks_tag_removal() { + let admin = @0xD; + let mut scenario = ts::begin(admin); + + { + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + clock.set_for_testing(initial_time_for_testing()); + + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + let initial_record = record::new_initial_record( + record::new_text(string::utf8(b"Tagged initial record")), + option::none(), + option::some(string::utf8(b"finance")), + ); + + let (admin_cap, _) = main::create( + option::some(initial_record), + locking_config, + option::none(), + option::none(), + vector[string::utf8(b"finance")], + &clock, + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(admin_cap, admin); + clock::destroy_for_testing(clock); + }; + + ts::next_tx(&mut scenario, admin); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + trail.remove_record_tag( + &admin_cap, + string::utf8(b"finance"), + &clock, + ts::ctx(&mut scenario), + ); + + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + }; + + ts::end(scenario); +} + #[test] fun test_create_minimal_metadata() { let user = @0xC; From 690f236e7eb3361c607c8fc7c6352b5b8e9d4b2a Mon Sep 17 00:00:00 2001 From: Yasir Date: Tue, 31 Mar 2026 15:15:44 +0300 Subject: [PATCH 115/189] fix capability selection and clean up core internals --- audit-trail-move/sources/audit_trail.move | 4 +- audit-trail-rs/src/core/access/operations.rs | 72 +++--- audit-trail-rs/src/core/create/operations.rs | 14 +- .../src/core/create/transactions.rs | 4 +- .../src/core/internal/capability.rs | 187 ++++++++++++++++ .../src/core/internal/linked_table.rs | 110 +++++++++ audit-trail-rs/src/core/internal/mod.rs | 8 + .../src/core/internal/move_collections.rs | 32 +++ audit-trail-rs/src/core/internal/trail.rs | 31 +++ .../src/core/{utils.rs => internal/tx.rs} | 203 ++++++++++------- audit-trail-rs/src/core/locking/operations.rs | 24 +- audit-trail-rs/src/core/mod.rs | 3 +- audit-trail-rs/src/core/operations.rs | 211 ------------------ audit-trail-rs/src/core/records/mod.rs | 61 +---- audit-trail-rs/src/core/records/operations.rs | 56 +++-- audit-trail-rs/src/core/tags/operations.rs | 14 +- audit-trail-rs/src/core/trail.rs | 3 +- audit-trail-rs/src/core/trail/operations.rs | 16 +- audit-trail-rs/src/core/types/audit_trail.rs | 7 +- audit-trail-rs/src/core/types/locking.rs | 14 +- audit-trail-rs/src/core/types/record.rs | 10 +- audit-trail-rs/src/core/types/role_map.rs | 6 +- audit-trail-rs/tests/e2e/records.rs | 115 ++++++++++ 23 files changed, 735 insertions(+), 470 deletions(-) create mode 100644 audit-trail-rs/src/core/internal/capability.rs create mode 100644 audit-trail-rs/src/core/internal/linked_table.rs create mode 100644 audit-trail-rs/src/core/internal/mod.rs create mode 100644 audit-trail-rs/src/core/internal/move_collections.rs create mode 100644 audit-trail-rs/src/core/internal/trail.rs rename audit-trail-rs/src/core/{utils.rs => internal/tx.rs} (50%) delete mode 100644 audit-trail-rs/src/core/operations.rs diff --git a/audit-trail-move/sources/audit_trail.move b/audit-trail-move/sources/audit_trail.move index dcde0b06..2fb5a92d 100644 --- a/audit-trail-move/sources/audit_trail.move +++ b/audit-trail-move/sources/audit_trail.move @@ -18,7 +18,7 @@ use audit_trail::{ set_write_lock }, permission::{Self, Permission}, - record::{Self, Record}, + record::{Self, Record, InitialRecord}, record_tags::{Self, RoleTags, TagRegistry} }; use iota::{clock::{Self, Clock}, event, linked_table::{Self, LinkedTable}, vec_set::VecSet}; @@ -152,7 +152,7 @@ public fun new_trail_metadata(name: String, description: Option): Immuta /// roles and issue capabilities to other users. /// * Trail ID public fun create( - initial_record: Option>, + initial_record: Option>, locking_config: LockingConfig, trail_metadata: Option, updatable_metadata: Option, diff --git a/audit-trail-rs/src/core/access/operations.rs b/audit-trail-rs/src/core/access/operations.rs index a9759125..0e57ea41 100644 --- a/audit-trail-rs/src/core/access/operations.rs +++ b/audit-trail-rs/src/core/access/operations.rs @@ -6,8 +6,8 @@ use iota_interaction::types::transaction::{ObjectArg, ProgrammableTransaction}; use iota_interaction::{OptionalSync, ident_str}; use product_common::core_client::CoreClientReadOnly; +use crate::core::internal::{trail as trail_reader, tx}; use crate::core::types::{CapabilityIssueOptions, Permission, PermissionSet, RoleTags}; -use crate::core::{operations, utils}; use crate::error::Error; pub(super) struct AccessOps; @@ -26,14 +26,14 @@ impl AccessOps { { assert_role_tags_defined(client, trail_id, &role_tags).await?; - operations::build_trail_transaction( + tx::build_trail_transaction( client, trail_id, owner, Permission::AddRoles, "create_role", |ptb, _| { - let role = utils::ptb_pure(ptb, "role", name)?; + let role = tx::ptb_pure(ptb, "role", name)?; let perms_vec = permissions.to_move_vec(client.package_id(), ptb)?; let perms = ptb.programmable_move_call( client.package_id(), @@ -46,13 +46,13 @@ impl AccessOps { Some(role_tags) => { let role_tags_arg = role_tags.to_ptb(ptb, client.package_id())?; - utils::option_to_move(Some(role_tags_arg), RoleTags::tag(client.package_id()), ptb) + tx::option_to_move(Some(role_tags_arg), RoleTags::tag(client.package_id()), ptb) .map_err(|e| Error::InvalidArgument(format!("failed to build role_tags option: {e}")))? } - None => utils::option_to_move(None, RoleTags::tag(client.package_id()), ptb) + None => tx::option_to_move(None, RoleTags::tag(client.package_id()), ptb) .map_err(|e| Error::InvalidArgument(format!("failed to build role_tags option: {e}")))?, }; - let clock = utils::get_clock_ref(ptb); + let clock = tx::get_clock_ref(ptb); Ok(vec![role, perms, role_tags_arg, clock]) }, @@ -73,14 +73,14 @@ impl AccessOps { { assert_role_tags_defined(client, trail_id, &role_tags).await?; - operations::build_trail_transaction( + tx::build_trail_transaction( client, trail_id, owner, Permission::UpdateRoles, "update_role_permissions", |ptb, _| { - let role = utils::ptb_pure(ptb, "role", name)?; + let role = tx::ptb_pure(ptb, "role", name)?; let perms_vec = permissions.to_move_vec(client.package_id(), ptb)?; let perms = ptb.programmable_move_call( @@ -93,14 +93,14 @@ impl AccessOps { let role_tags_arg = match role_tags { Some(role_tags) => { let role_tags_arg = role_tags.to_ptb(ptb, client.package_id())?; - utils::option_to_move(Some(role_tags_arg), RoleTags::tag(client.package_id()), ptb) + tx::option_to_move(Some(role_tags_arg), RoleTags::tag(client.package_id()), ptb) .map_err(|e| Error::InvalidArgument(format!("failed to build role_tags option: {e}")))? } - None => utils::option_to_move(None, RoleTags::tag(client.package_id()), ptb) + None => tx::option_to_move(None, RoleTags::tag(client.package_id()), ptb) .map_err(|e| Error::InvalidArgument(format!("failed to build role_tags option: {e}")))?, }; - let clock = utils::get_clock_ref(ptb); + let clock = tx::get_clock_ref(ptb); Ok(vec![role, perms, role_tags_arg, clock]) }, @@ -117,15 +117,15 @@ impl AccessOps { where C: CoreClientReadOnly + OptionalSync, { - operations::build_trail_transaction( + tx::build_trail_transaction( client, trail_id, owner, Permission::DeleteRoles, "delete_role", |ptb, _| { - let role = utils::ptb_pure(ptb, "role", name)?; - let clock = utils::get_clock_ref(ptb); + let role = tx::ptb_pure(ptb, "role", name)?; + let clock = tx::get_clock_ref(ptb); Ok(vec![role, clock]) }, @@ -143,18 +143,18 @@ impl AccessOps { where C: CoreClientReadOnly + OptionalSync, { - operations::build_trail_transaction( + tx::build_trail_transaction( client, trail_id, owner, Permission::AddCapabilities, "new_capability", |ptb, _| { - let role = utils::ptb_pure(ptb, "role", role_name)?; - let issued_to = utils::ptb_pure(ptb, "issued_to", options.issued_to)?; - let valid_from = utils::ptb_pure(ptb, "valid_from", options.valid_from_ms)?; - let valid_until = utils::ptb_pure(ptb, "valid_until", options.valid_until_ms)?; - let clock = utils::get_clock_ref(ptb); + let role = tx::ptb_pure(ptb, "role", role_name)?; + let issued_to = tx::ptb_pure(ptb, "issued_to", options.issued_to)?; + let valid_from = tx::ptb_pure(ptb, "valid_from", options.valid_from_ms)?; + let valid_until = tx::ptb_pure(ptb, "valid_until", options.valid_until_ms)?; + let clock = tx::get_clock_ref(ptb); Ok(vec![role, issued_to, valid_from, valid_until, clock]) }, @@ -172,16 +172,16 @@ impl AccessOps { where C: CoreClientReadOnly + OptionalSync, { - operations::build_trail_transaction( + tx::build_trail_transaction( client, trail_id, owner, Permission::RevokeCapabilities, "revoke_capability", |ptb, _| { - let cap = utils::ptb_pure(ptb, "capability_id", capability_id)?; - let valid_until = utils::ptb_pure(ptb, "capability_valid_until", capability_valid_until)?; - let clock = utils::get_clock_ref(ptb); + let cap = tx::ptb_pure(ptb, "capability_id", capability_id)?; + let valid_until = tx::ptb_pure(ptb, "capability_valid_until", capability_valid_until)?; + let clock = tx::get_clock_ref(ptb); Ok(vec![cap, valid_until, clock]) }, @@ -198,9 +198,9 @@ impl AccessOps { where C: CoreClientReadOnly + OptionalSync, { - let capability_ref = utils::get_object_ref_by_id(client, &capability_id).await?; + let capability_ref = tx::get_object_ref_by_id(client, &capability_id).await?; - operations::build_trail_transaction( + tx::build_trail_transaction( client, trail_id, owner, @@ -210,7 +210,7 @@ impl AccessOps { let cap_to_destroy = ptb .obj(ObjectArg::ImmOrOwnedObject(capability_ref)) .map_err(|e| Error::InvalidArgument(format!("Failed to create capability argument: {e}")))?; - let clock = utils::get_clock_ref(ptb); + let clock = tx::get_clock_ref(ptb); Ok(vec![cap_to_destroy, clock]) }, @@ -226,8 +226,8 @@ impl AccessOps { where C: CoreClientReadOnly + OptionalSync, { - let cap_ref = utils::get_object_ref_by_id(client, &capability_id).await?; - operations::build_trail_transaction_with_cap_ref( + let cap_ref = tx::get_object_ref_by_id(client, &capability_id).await?; + tx::build_trail_transaction_with_cap_ref( client, trail_id, cap_ref, @@ -247,16 +247,16 @@ impl AccessOps { where C: CoreClientReadOnly + OptionalSync, { - operations::build_trail_transaction( + tx::build_trail_transaction( client, trail_id, owner, Permission::RevokeCapabilities, "revoke_initial_admin_capability", |ptb, _| { - let cap = utils::ptb_pure(ptb, "capability_id", capability_id)?; - let valid_until = utils::ptb_pure(ptb, "capability_valid_until", capability_valid_until)?; - let clock = utils::get_clock_ref(ptb); + let cap = tx::ptb_pure(ptb, "capability_id", capability_id)?; + let valid_until = tx::ptb_pure(ptb, "capability_valid_until", capability_valid_until)?; + let clock = tx::get_clock_ref(ptb); Ok(vec![cap, valid_until, clock]) }, @@ -272,14 +272,14 @@ impl AccessOps { where C: CoreClientReadOnly + OptionalSync, { - operations::build_trail_transaction( + tx::build_trail_transaction( client, trail_id, owner, Permission::RevokeCapabilities, "cleanup_revoked_capabilities", |ptb, _| { - let clock = utils::get_clock_ref(ptb); + let clock = tx::get_clock_ref(ptb); Ok(vec![clock]) }, ) @@ -295,7 +295,7 @@ where return Ok(()); }; - let trail = operations::get_audit_trail(trail_id, client).await?; + let trail = trail_reader::get_audit_trail(trail_id, client).await?; let undefined_tags = role_tags .tags .iter() diff --git a/audit-trail-rs/src/core/create/operations.rs b/audit-trail-rs/src/core/create/operations.rs index 6d3a54b9..87b457dd 100644 --- a/audit-trail-rs/src/core/create/operations.rs +++ b/audit-trail-rs/src/core/create/operations.rs @@ -8,8 +8,8 @@ use iota_interaction::types::base_types::{IotaAddress, ObjectID}; use iota_interaction::types::programmable_transaction_builder::ProgrammableTransactionBuilder; use iota_interaction::types::transaction::{Argument, ProgrammableTransaction}; +use crate::core::internal::tx; use crate::core::types::{ImmutableMetadata, InitialRecord, LockingConfig}; -use crate::core::utils; use crate::error::Error; pub(super) struct CreateOps; @@ -47,7 +47,7 @@ impl CreateOps { let data_tag = initial_record.data.tag(); let initial_record_tag = InitialRecord::tag(audit_trail_package_id, &data_tag); let initial_record_arg = initial_record.into_ptb(&mut ptb, audit_trail_package_id)?; - let initial_record = utils::option_to_move(Some(initial_record_arg), initial_record_tag, &mut ptb) + let initial_record = tx::option_to_move(Some(initial_record_arg), initial_record_tag, &mut ptb) .map_err(|e| Error::InvalidArgument(format!("failed to build initial_record option: {e}")))?; let locking_config = locking_config.to_ptb(&mut ptb, audit_trail_package_id, tf_components_package_id)?; @@ -56,21 +56,21 @@ impl CreateOps { let trail_metadata = match trail_metadata { Some(metadata) => { let metadata_arg = metadata.to_ptb(&mut ptb, audit_trail_package_id)?; - utils::option_to_move(Some(metadata_arg), immutable_metadata_tag, &mut ptb) + tx::option_to_move(Some(metadata_arg), immutable_metadata_tag, &mut ptb) .map_err(|e| Error::InvalidArgument(format!("failed to build trail_metadata option: {e}")))? } - None => utils::option_to_move(None, immutable_metadata_tag, &mut ptb) + None => tx::option_to_move(None, immutable_metadata_tag, &mut ptb) .map_err(|e| Error::InvalidArgument(format!("failed to build trail_metadata option: {e}")))?, }; - let updatable_metadata = utils::ptb_pure(&mut ptb, "updatable_metadata", updatable_metadata)?; + let updatable_metadata = tx::ptb_pure(&mut ptb, "updatable_metadata", updatable_metadata)?; let record_tags = { let mut record_tags = record_tags.into_iter().collect::>(); record_tags.sort(); - utils::ptb_pure(&mut ptb, "record_tags", record_tags)? + tx::ptb_pure(&mut ptb, "record_tags", record_tags)? }; - let clock = utils::get_clock_ref(&mut ptb); + let clock = tx::get_clock_ref(&mut ptb); let result = ptb.programmable_move_call( audit_trail_package_id, diff --git a/audit-trail-rs/src/core/create/transactions.rs b/audit-trail-rs/src/core/create/transactions.rs index 7427004b..e13daa8a 100644 --- a/audit-trail-rs/src/core/create/transactions.rs +++ b/audit-trail-rs/src/core/create/transactions.rs @@ -12,7 +12,7 @@ use tokio::sync::OnceCell; use super::operations::{CreateOps, CreateTrailArgs}; use crate::core::builder::AuditTrailBuilder; -use crate::core::operations; +use crate::core::internal::trail as trail_reader; use crate::core::types::{AuditTrailCreated, Event, OnChainAuditTrail}; use crate::error::Error; @@ -29,7 +29,7 @@ impl TrailCreated { where C: CoreClientReadOnly + OptionalSync, { - operations::get_audit_trail(self.trail_id, client).await + trail_reader::get_audit_trail(self.trail_id, client).await } } diff --git a/audit-trail-rs/src/core/internal/capability.rs b/audit-trail-rs/src/core/internal/capability.rs new file mode 100644 index 00000000..bb5fc729 --- /dev/null +++ b/audit-trail-rs/src/core/internal/capability.rs @@ -0,0 +1,187 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::HashSet; + +use iota_interaction::move_types::language_storage::StructTag; +use iota_interaction::rpc_types::{ + IotaObjectDataFilter, IotaObjectDataOptions, IotaObjectResponseQuery, IotaParsedData, +}; +use iota_interaction::types::TypeTag; +use iota_interaction::types::base_types::{IotaAddress, ObjectID, ObjectRef}; +use iota_interaction::types::id::ID; +use iota_interaction::{IotaClientTrait, OptionalSync}; +use product_common::core_client::CoreClientReadOnly; + +use super::linked_table; +use super::tx; +use crate::core::types::{Capability, OnChainAuditTrail, Permission}; +use crate::error::Error; + +pub(crate) async fn find_capable_cap( + client: &C, + owner: IotaAddress, + trail_id: ObjectID, + trail: &OnChainAuditTrail, + permission: Permission, +) -> Result +where + C: CoreClientReadOnly + OptionalSync, +{ + let valid_roles: HashSet = trail + .roles + .roles + .iter() + .filter(|(_, role)| role.permissions.contains(&permission)) + .map(|(name, _)| name.clone()) + .collect(); + + let cap = find_owned_capability(client, owner, trail, |cap| { + cap.matches_target_and_role(trail_id, &valid_roles) + }) + .await? + .ok_or_else(|| { + Error::InvalidArgument(format!( + "no capability with {:?} permission found for owner {owner} and trail {trail_id}", + permission + )) + })?; + + let object_id = *cap.id.object_id(); + tx::get_object_ref_by_id(client, &object_id).await +} + +pub(crate) async fn find_owned_capability( + client: &C, + owner: IotaAddress, + trail: &OnChainAuditTrail, + predicate: P, +) -> Result, Error> +where + C: CoreClientReadOnly + OptionalSync, + P: Fn(&Capability) -> bool + Send, +{ + let revoked_capability_ids = revoked_capability_ids(client, trail).await?; + let tf_components_package_id = client + .tf_components_package_id() + .expect("TfComponents package ID should be present for audit trail clients"); + let capability_struct_tag: StructTag = Capability::type_tag(tf_components_package_id) + .to_string() + .parse() + .expect("capability type tag is a valid struct tag"); + let query = IotaObjectResponseQuery::new( + Some(IotaObjectDataFilter::StructType(capability_struct_tag)), + Some(IotaObjectDataOptions::default().with_content()), + ); + + let mut cursor = None; + loop { + let mut page = client + .client_adapter() + .read_api() + .get_owned_objects(owner, Some(query.clone()), cursor, Some(25)) + .await + .map_err(|e| Error::RpcError(e.to_string()))?; + + let maybe_cap = std::mem::take(&mut page.data) + .into_iter() + .filter_map(|res| res.data) + .filter_map(|data| data.content) + .filter_map(|obj_data| { + let IotaParsedData::MoveObject(move_object) = obj_data else { + unreachable!() + }; + serde_json::from_value(move_object.fields.to_json_value()).ok() + }) + .find(|cap| capability_matches(cap, owner, &revoked_capability_ids, &predicate)); + cursor = page.next_cursor; + + if maybe_cap.is_some() { + return Ok(maybe_cap); + } + if !page.has_next_page { + break; + } + } + + Ok(None) +} + +async fn revoked_capability_ids(client: &C, trail: &OnChainAuditTrail) -> Result, Error> +where + C: CoreClientReadOnly + OptionalSync, +{ + linked_table::collect_keys::<_, ObjectID, u64>( + client, + &trail.roles.revoked_capabilities, + TypeTag::Struct(Box::new(ID::type_())), + ) + .await +} + +fn capability_matches

( + cap: &Capability, + owner: IotaAddress, + revoked_capability_ids: &HashSet, + predicate: &P, +) -> bool +where + P: Fn(&Capability) -> bool, +{ + predicate(cap) + && !revoked_capability_ids.contains(cap.id.object_id()) + && cap.issued_to.map(|issued_to| issued_to == owner).unwrap_or(true) +} + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use iota_interaction::types::base_types::{IotaAddress, ObjectID, dbg_object_id}; + use iota_interaction::types::id::UID; + + use super::capability_matches; + use crate::core::types::Capability; + + #[test] + fn capability_matches_skips_revoked_caps() { + let owner = IotaAddress::random_for_testing_only(); + let trail_id = dbg_object_id(1); + let revoked_cap_id = dbg_object_id(2); + let valid_cap_id = dbg_object_id(3); + let valid_roles = HashSet::from(["Writer".to_string()]); + let revoked_ids = HashSet::from([revoked_cap_id]); + + let revoked_cap = make_capability(revoked_cap_id, trail_id, "Writer", None); + let valid_cap = make_capability(valid_cap_id, trail_id, "Writer", None); + + assert!(!capability_matches(&revoked_cap, owner, &revoked_ids, &|cap| cap + .matches_target_and_role(trail_id, &valid_roles))); + assert!(capability_matches(&valid_cap, owner, &revoked_ids, &|cap| cap + .matches_target_and_role(trail_id, &valid_roles))); + } + + #[test] + fn capability_matches_skips_issued_to_mismatch() { + let owner = IotaAddress::random_for_testing_only(); + let other_owner = IotaAddress::random_for_testing_only(); + let trail_id = dbg_object_id(4); + let valid_roles = HashSet::from(["Writer".to_string()]); + let cap = make_capability(dbg_object_id(5), trail_id, "Writer", Some(other_owner)); + + assert!(!capability_matches(&cap, owner, &HashSet::new(), &|candidate| { + candidate.matches_target_and_role(trail_id, &valid_roles) + })); + } + + fn make_capability(id: ObjectID, trail_id: ObjectID, role: &str, issued_to: Option) -> Capability { + Capability { + id: UID::new(id), + target_key: trail_id, + role: role.to_string(), + issued_to, + valid_from: None, + valid_until: None, + } + } +} diff --git a/audit-trail-rs/src/core/internal/linked_table.rs b/audit-trail-rs/src/core/internal/linked_table.rs new file mode 100644 index 00000000..9588e99a --- /dev/null +++ b/audit-trail-rs/src/core/internal/linked_table.rs @@ -0,0 +1,110 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::HashSet; +use std::fmt::Display; +use std::hash::Hash; + +use iota_interaction::rpc_types::{IotaData as _, IotaObjectDataOptions}; +use iota_interaction::types::TypeTag; +use iota_interaction::types::base_types::ObjectID; +use iota_interaction::types::collection_types::{LinkedTable, LinkedTableNode}; +use iota_interaction::types::dynamic_field::{DynamicFieldName, Field}; +use iota_interaction::{IotaClientTrait, OptionalSync}; +use product_common::core_client::CoreClientReadOnly; +use serde::Serialize; +use serde::de::DeserializeOwned; + +use crate::error::Error; + +pub(crate) async fn collect_keys( + client: &C, + table: &LinkedTable, + key_type: TypeTag, +) -> Result, Error> +where + C: CoreClientReadOnly + OptionalSync, + K: Clone + DeserializeOwned + Display + Eq + Hash + Serialize, + V: DeserializeOwned, +{ + let expected = table.size as usize; + let mut cursor = table.head.clone(); + let mut keys = HashSet::with_capacity(expected); + + while let Some(key) = cursor { + if !keys.insert(key.clone()) { + return Err(Error::UnexpectedApiResponse(format!( + "cycle detected while traversing linked-table {table_id}; repeated key {key}", + table_id = table.id + ))); + } + + let node = fetch_node::<_, K, V>(client, table.id, &key, key_type.clone()).await?; + cursor = node.next; + } + + if keys.len() != expected { + return Err(Error::UnexpectedApiResponse(format!( + "linked-table traversal mismatch; expected {expected} entries, got {}", + keys.len() + ))); + } + + Ok(keys) +} + +pub(crate) async fn fetch_node( + client: &C, + table_id: ObjectID, + key: &K, + key_type: TypeTag, +) -> Result, Error> +where + C: CoreClientReadOnly + OptionalSync, + K: Clone + DeserializeOwned + Display + Serialize, + V: DeserializeOwned, +{ + let name = DynamicFieldName { + type_: key_type, + value: serde_json::to_value(key).map_err(|err| { + Error::UnexpectedApiResponse(format!( + "failed to encode linked-table dynamic-field key {key} for table {table_id}; {err}" + )) + })?, + }; + + let data = client + .client_adapter() + .read_api() + .get_dynamic_field_object_v2(table_id, name, Some(IotaObjectDataOptions::bcs_lossless())) + .await + .map_err(|err| Error::RpcError(err.to_string()))? + .data + .ok_or_else(|| { + Error::UnexpectedApiResponse(format!( + "dynamic-field object not found for linked-table id {table_id} and key {key}" + )) + })?; + + let field: Field> = data + .bcs + .ok_or_else(|| { + Error::UnexpectedApiResponse(format!( + "linked-table node {} missing bcs object content", + data.object_id + )) + })? + .try_into_move() + .ok_or_else(|| { + Error::UnexpectedApiResponse(format!( + "linked-table node {} bcs content is not a move object", + data.object_id + )) + })? + .deserialize() + .map_err(|err| { + Error::UnexpectedApiResponse(format!("failed to decode linked-table node {}; {err}", data.object_id)) + })?; + + Ok(field.value) +} diff --git a/audit-trail-rs/src/core/internal/mod.rs b/audit-trail-rs/src/core/internal/mod.rs new file mode 100644 index 00000000..44b8c792 --- /dev/null +++ b/audit-trail-rs/src/core/internal/mod.rs @@ -0,0 +1,8 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +pub(crate) mod capability; +pub(crate) mod linked_table; +pub(crate) mod move_collections; +pub(crate) mod trail; +pub(crate) mod tx; diff --git a/audit-trail-rs/src/core/internal/move_collections.rs b/audit-trail-rs/src/core/internal/move_collections.rs new file mode 100644 index 00000000..9df1cc84 --- /dev/null +++ b/audit-trail-rs/src/core/internal/move_collections.rs @@ -0,0 +1,32 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::{HashMap, HashSet}; +use std::fmt::Debug; +use std::hash::Hash; + +use iota_interaction::types::collection_types::{VecMap, VecSet}; +use serde::{Deserialize, Deserializer}; + +pub(crate) fn deserialize_vec_map<'de, D, K, V>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, + K: Deserialize<'de> + Eq + Hash + Debug, + V: Deserialize<'de> + Debug, +{ + let vec_map = VecMap::::deserialize(deserializer)?; + Ok(vec_map + .contents + .into_iter() + .map(|entry| (entry.key, entry.value)) + .collect()) +} + +pub(crate) fn deserialize_vec_set<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, + T: Deserialize<'de> + Eq + Hash, +{ + let vec_set = VecSet::::deserialize(deserializer)?; + Ok(vec_set.contents.into_iter().collect()) +} diff --git a/audit-trail-rs/src/core/internal/trail.rs b/audit-trail-rs/src/core/internal/trail.rs new file mode 100644 index 00000000..7c898013 --- /dev/null +++ b/audit-trail-rs/src/core/internal/trail.rs @@ -0,0 +1,31 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use iota_interaction::rpc_types::{IotaData as _, IotaObjectDataOptions}; +use iota_interaction::{IotaClientTrait, OptionalSync}; +use product_common::core_client::CoreClientReadOnly; + +use crate::core::types::OnChainAuditTrail; +use crate::error::Error; +use iota_interaction::types::base_types::ObjectID; + +pub(crate) async fn get_audit_trail(trail_id: ObjectID, client: &C) -> Result +where + C: CoreClientReadOnly + OptionalSync, +{ + let data = client + .client_adapter() + .read_api() + .get_object_with_options(trail_id, IotaObjectDataOptions::bcs_lossless()) + .await + .map_err(|e| Error::UnexpectedApiResponse(format!("failed to fetch trail {} object; {e}", trail_id)))? + .data + .ok_or_else(|| Error::UnexpectedApiResponse(format!("trail {} data not found", trail_id)))?; + + data.bcs + .ok_or_else(|| Error::UnexpectedApiResponse(format!("trail {} missing bcs object content", trail_id)))? + .try_into_move() + .ok_or_else(|| Error::UnexpectedApiResponse(format!("trail {} bcs content is not a move object", trail_id)))? + .deserialize() + .map_err(|e| Error::UnexpectedApiResponse(format!("failed to decode trail {} bcs data; {e}", trail_id))) +} diff --git a/audit-trail-rs/src/core/utils.rs b/audit-trail-rs/src/core/internal/tx.rs similarity index 50% rename from audit-trail-rs/src/core/utils.rs rename to audit-trail-rs/src/core/internal/tx.rs index 80b4bb97..2d0075ff 100644 --- a/audit-trail-rs/src/core/utils.rs +++ b/audit-trail-rs/src/core/internal/tx.rs @@ -1,29 +1,27 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use std::collections::{HashMap, HashSet}; -use std::fmt::Debug; -use std::hash::Hash; use std::str::FromStr; use iota_interaction::rpc_types::IotaObjectDataOptions; -use iota_interaction::types::base_types::{ObjectID, ObjectRef, STD_OPTION_MODULE_NAME}; -use iota_interaction::types::collection_types::{VecMap, VecSet}; +use iota_interaction::types::base_types::{IotaAddress, ObjectID, ObjectRef, STD_OPTION_MODULE_NAME}; use iota_interaction::types::object::Owner; use iota_interaction::types::programmable_transaction_builder::{ ProgrammableTransactionBuilder as Ptb, ProgrammableTransactionBuilder, }; -use iota_interaction::types::transaction::{Argument, ObjectArg}; +use iota_interaction::types::transaction::{Argument, ObjectArg, ProgrammableTransaction}; use iota_interaction::types::{ - IOTA_CLOCK_OBJECT_ID, IOTA_CLOCK_OBJECT_SHARED_VERSION, MOVE_STDLIB_PACKAGE_ID, TypeTag, + IOTA_CLOCK_OBJECT_ID, IOTA_CLOCK_OBJECT_SHARED_VERSION, Identifier, MOVE_STDLIB_PACKAGE_ID, TypeTag, }; use iota_interaction::{IotaClientTrait, OptionalSync, ident_str}; use product_common::core_client::CoreClientReadOnly; -use serde::{Deserialize, Deserializer, Serialize}; +use serde::Serialize; +use super::capability; +use super::trail as trail_reader; +use crate::core::types::Permission; use crate::error::Error; -/// Adds a reference to the on-chain clock to `ptb`'s arguments. pub(crate) fn get_clock_ref(ptb: &mut Ptb) -> Argument { ptb.obj(ObjectArg::SharedObject { id: IOTA_CLOCK_OBJECT_ID, @@ -44,7 +42,113 @@ where }) } -/// Get the type tag of an object +pub(crate) fn option_to_move( + option: Option, + tag: TypeTag, + ptb: &mut ProgrammableTransactionBuilder, +) -> Result { + let arg = if let Some(t) = option { + ptb.programmable_move_call( + MOVE_STDLIB_PACKAGE_ID, + STD_OPTION_MODULE_NAME.into(), + ident_str!("some").into(), + vec![tag], + vec![t], + ) + } else { + ptb.programmable_move_call( + MOVE_STDLIB_PACKAGE_ID, + STD_OPTION_MODULE_NAME.into(), + ident_str!("none").into(), + vec![tag], + vec![], + ) + }; + + Ok(arg) +} + +pub(crate) async fn build_trail_transaction( + client: &C, + trail_id: ObjectID, + owner: IotaAddress, + permission: Permission, + method: impl AsRef, + additional_args: F, +) -> Result +where + F: FnOnce(&mut ProgrammableTransactionBuilder, &TypeTag) -> Result, Error>, + C: CoreClientReadOnly + OptionalSync, +{ + let trail = trail_reader::get_audit_trail(trail_id, client).await?; + let cap_ref = capability::find_capable_cap(client, owner, trail_id, &trail, permission).await?; + build_trail_transaction_with_cap_ref(client, trail_id, cap_ref, method, additional_args).await +} + +pub(crate) async fn build_trail_transaction_with_cap_ref( + client: &C, + trail_id: ObjectID, + cap_ref: ObjectRef, + method: impl AsRef, + additional_args: F, +) -> Result +where + F: FnOnce(&mut ProgrammableTransactionBuilder, &TypeTag) -> Result, Error>, + C: CoreClientReadOnly + OptionalSync, +{ + let mut ptb = ProgrammableTransactionBuilder::new(); + + let type_tag = get_type_tag(client, &trail_id).await?; + let tag = vec![type_tag.clone()]; + let trail_arg = get_shared_object_arg(client, &trail_id, true).await?; + + let mut args = vec![ + ptb.obj(trail_arg) + .map_err(|e| Error::InvalidArgument(format!("Failed to create trail argument: {e}")))?, + ptb.obj(ObjectArg::ImmOrOwnedObject(cap_ref)) + .map_err(|e| Error::InvalidArgument(format!("Failed to create cap argument: {e}")))?, + ]; + + args.extend(additional_args(&mut ptb, &type_tag)?); + + let function = Identifier::from_str(method.as_ref()) + .map_err(|e| Error::InvalidArgument(format!("Invalid method name '{}': {e}", method.as_ref())))?; + + ptb.programmable_move_call(client.package_id(), ident_str!("main").into(), function, tag, args); + + Ok(ptb.finish()) +} + +pub(crate) async fn build_read_only_transaction( + client: &C, + trail_id: ObjectID, + method: impl AsRef, + additional_args: F, +) -> Result +where + F: FnOnce(&mut ProgrammableTransactionBuilder) -> Result, Error>, + C: CoreClientReadOnly + OptionalSync, +{ + let mut ptb = ProgrammableTransactionBuilder::new(); + + let tag = vec![get_type_tag(client, &trail_id).await?]; + let trail_arg = get_shared_object_arg(client, &trail_id, false).await?; + + let mut args = vec![ + ptb.obj(trail_arg) + .map_err(|e| Error::InvalidArgument(format!("Failed to create trail argument: {e}")))?, + ]; + + args.extend(additional_args(&mut ptb)?); + + let function = Identifier::from_str(method.as_ref()) + .map_err(|e| Error::InvalidArgument(format!("Invalid method name '{}': {e}", method.as_ref())))?; + + ptb.programmable_move_call(client.package_id(), ident_str!("main").into(), function, tag, args); + + Ok(ptb.finish()) +} + pub(crate) async fn get_type_tag(client: &C, object_id: &ObjectID) -> Result where C: CoreClientReadOnly + OptionalSync, @@ -67,14 +171,11 @@ where let type_param_str = parse_type(&full_type_str)?; - let tag = TypeTag::from_str(&type_param_str) - .map_err(|e| Error::FailedToParseTag(format!("Failed to parse tag '{type_param_str}': {e}")))?; - - Ok(tag) + TypeTag::from_str(&type_param_str) + .map_err(|e| Error::FailedToParseTag(format!("Failed to parse tag '{type_param_str}': {e}"))) } -/// Parses the type string to get the generic argument -pub(crate) fn parse_type(full_type: &str) -> Result { +fn parse_type(full_type: &str) -> Result { if let (Some(start), Some(end)) = (full_type.find('<'), full_type.rfind('>')) { Ok(full_type[start + 1..end].to_string()) } else { @@ -85,13 +186,13 @@ pub(crate) fn parse_type(full_type: &str) -> Result { } pub(crate) async fn get_object_ref_by_id( - iota_client: &impl CoreClientReadOnly, - obj: &ObjectID, + client: &impl CoreClientReadOnly, + object_id: &ObjectID, ) -> Result { - let res = iota_client + let res = client .client_adapter() .read_api() - .get_object_with_options(*obj, IotaObjectDataOptions::new().with_content()) + .get_object_with_options(*object_id, IotaObjectDataOptions::new().with_content()) .await .map_err(|err| Error::GenericError(format!("Failed to get object: {err}")))?; @@ -103,14 +204,14 @@ pub(crate) async fn get_object_ref_by_id( } pub(crate) async fn get_shared_object_arg( - iota_client: &impl CoreClientReadOnly, - obj: &ObjectID, + client: &impl CoreClientReadOnly, + object_id: &ObjectID, mutable: bool, ) -> Result { - let res = iota_client + let res = client .client_adapter() .read_api() - .get_object_with_options(*obj, IotaObjectDataOptions::new().with_owner()) + .get_object_with_options(*object_id, IotaObjectDataOptions::new().with_owner()) .await .map_err(|err| Error::GenericError(format!("Failed to get object: {err}")))?; @@ -120,62 +221,10 @@ pub(crate) async fn get_shared_object_arg( match data.owner { Some(Owner::Shared { initial_shared_version }) => Ok(ObjectArg::SharedObject { - id: *obj, + id: *object_id, initial_shared_version, mutable, }), _ => Err(Error::InvalidArgument("object is not shared".to_string())), } } - -/// Deserialize a [`VecMap`] into a [`HashMap`] -pub(crate) fn deserialize_vec_map<'de, D, K, V>(deserializer: D) -> Result, D::Error> -where - D: Deserializer<'de>, - K: Deserialize<'de> + Eq + Hash + Debug, - V: Deserialize<'de> + Debug, -{ - let vec_map = VecMap::::deserialize(deserializer)?; - Ok(vec_map - .contents - .into_iter() - .map(|entry| (entry.key, entry.value)) - .collect()) -} - -/// Deserialize a [`VecSet`] into a [`HashSet`] -pub(crate) fn deserialize_vec_set<'de, D, T>(deserializer: D) -> Result, D::Error> -where - D: Deserializer<'de>, - T: Deserialize<'de> + Eq + Hash, -{ - let vec_set = VecSet::::deserialize(deserializer)?; - Ok(vec_set.contents.into_iter().collect()) -} - -/// Convert an option value into a [`ProgrammableMoveCall`] argument -pub(crate) fn option_to_move( - option: Option, - tag: TypeTag, - ptb: &mut ProgrammableTransactionBuilder, -) -> Result { - let arg = if let Some(t) = option { - ptb.programmable_move_call( - MOVE_STDLIB_PACKAGE_ID, - STD_OPTION_MODULE_NAME.into(), - ident_str!("some").into(), - vec![tag], - vec![t], - ) - } else { - ptb.programmable_move_call( - MOVE_STDLIB_PACKAGE_ID, - STD_OPTION_MODULE_NAME.into(), - ident_str!("none").into(), - vec![tag], - vec![], - ) - }; - - Ok(arg) -} diff --git a/audit-trail-rs/src/core/locking/operations.rs b/audit-trail-rs/src/core/locking/operations.rs index 8ef5b66f..9d1a9469 100644 --- a/audit-trail-rs/src/core/locking/operations.rs +++ b/audit-trail-rs/src/core/locking/operations.rs @@ -6,8 +6,8 @@ use iota_interaction::types::base_types::{IotaAddress, ObjectID}; use iota_interaction::types::transaction::ProgrammableTransaction; use product_common::core_client::CoreClientReadOnly; +use crate::core::internal::tx; use crate::core::types::{LockingConfig, LockingWindow, Permission, TimeLock}; -use crate::core::{operations, utils}; use crate::error::Error; pub(super) struct LockingOps; @@ -26,7 +26,7 @@ impl LockingOps { .tf_components_package_id() .expect("TfComponents package ID should be present for audit trail clients"); - operations::build_trail_transaction( + tx::build_trail_transaction( client, trail_id, owner, @@ -34,7 +34,7 @@ impl LockingOps { "update_locking_config", |ptb, _| { let config = new_config.to_ptb(ptb, client.package_id(), tf_components_package_id)?; - let clock = utils::get_clock_ref(ptb); + let clock = tx::get_clock_ref(ptb); Ok(vec![config, clock]) }, @@ -51,7 +51,7 @@ impl LockingOps { where C: CoreClientReadOnly + OptionalSync, { - operations::build_trail_transaction( + tx::build_trail_transaction( client, trail_id, owner, @@ -59,7 +59,7 @@ impl LockingOps { "update_delete_record_window", |ptb, _| { let window = new_delete_record_window.to_ptb(ptb, client.package_id())?; - let clock = utils::get_clock_ref(ptb); + let clock = tx::get_clock_ref(ptb); Ok(vec![window, clock]) }, @@ -79,7 +79,7 @@ impl LockingOps { let tf_components_package_id = client .tf_components_package_id() .expect("TfComponents package ID should be present for audit trail clients"); - operations::build_trail_transaction( + tx::build_trail_transaction( client, trail_id, owner, @@ -87,7 +87,7 @@ impl LockingOps { "update_delete_trail_lock", |ptb, _| { let delete_trail_lock = new_delete_trail_lock.to_ptb(ptb, tf_components_package_id)?; - let clock = utils::get_clock_ref(ptb); + let clock = tx::get_clock_ref(ptb); Ok(vec![delete_trail_lock, clock]) }, @@ -107,7 +107,7 @@ impl LockingOps { let tf_components_package_id = client .tf_components_package_id() .expect("TfComponents package ID should be present for audit trail clients"); - operations::build_trail_transaction( + tx::build_trail_transaction( client, trail_id, owner, @@ -115,7 +115,7 @@ impl LockingOps { "update_write_lock", |ptb, _| { let write_lock = new_write_lock.to_ptb(ptb, tf_components_package_id)?; - let clock = utils::get_clock_ref(ptb); + let clock = tx::get_clock_ref(ptb); Ok(vec![write_lock, clock]) }, @@ -131,9 +131,9 @@ impl LockingOps { where C: CoreClientReadOnly + OptionalSync, { - operations::build_read_only_transaction(client, trail_id, "is_record_locked", |ptb| { - let sequence_number = utils::ptb_pure(ptb, "sequence_number", sequence_number)?; - let clock = utils::get_clock_ref(ptb); + tx::build_read_only_transaction(client, trail_id, "is_record_locked", |ptb| { + let sequence_number = tx::ptb_pure(ptb, "sequence_number", sequence_number)?; + let clock = tx::get_clock_ref(ptb); Ok(vec![sequence_number, clock]) }) diff --git a/audit-trail-rs/src/core/mod.rs b/audit-trail-rs/src/core/mod.rs index a34108c5..36584101 100644 --- a/audit-trail-rs/src/core/mod.rs +++ b/audit-trail-rs/src/core/mod.rs @@ -6,10 +6,9 @@ pub mod access; pub mod builder; pub mod create; +pub(crate) mod internal; pub mod locking; -pub(crate) mod operations; pub mod records; pub mod tags; pub mod trail; pub mod types; -pub(crate) mod utils; diff --git a/audit-trail-rs/src/core/operations.rs b/audit-trail-rs/src/core/operations.rs deleted file mode 100644 index 19eeca55..00000000 --- a/audit-trail-rs/src/core/operations.rs +++ /dev/null @@ -1,211 +0,0 @@ -// Copyright 2020-2026 IOTA Stiftung -// SPDX-License-Identifier: Apache-2.0 - -use std::collections::HashSet; -use std::str::FromStr; - -use iota_interaction::move_types::language_storage::StructTag; -use iota_interaction::rpc_types::{ - IotaData as _, IotaObjectDataFilter, IotaObjectDataOptions, IotaObjectResponseQuery, IotaParsedData, -}; -use iota_interaction::types::base_types::{IotaAddress, ObjectID, ObjectRef}; -use iota_interaction::types::programmable_transaction_builder::ProgrammableTransactionBuilder; -use iota_interaction::types::transaction::{Argument, ObjectArg, ProgrammableTransaction}; -use iota_interaction::types::{Identifier, TypeTag}; -use iota_interaction::{IotaClientTrait, OptionalSync, ident_str}; -use product_common::core_client::CoreClientReadOnly; - -use crate::core::types::{Capability, OnChainAuditTrail, Permission}; -use crate::core::utils; -use crate::error::Error; - -pub async fn get_audit_trail(trail_id: ObjectID, client: &C) -> Result -where - C: CoreClientReadOnly + OptionalSync, -{ - let data = client - .client_adapter() - .read_api() - .get_object_with_options(trail_id, IotaObjectDataOptions::bcs_lossless()) - .await - .map_err(|e| Error::UnexpectedApiResponse(format!("failed to fetch trail {} object; {e}", trail_id)))? - .data - .ok_or_else(|| Error::UnexpectedApiResponse(format!("trail {} data not found", trail_id)))?; - - data.bcs - .ok_or_else(|| Error::UnexpectedApiResponse(format!("trail {} missing bcs object content", trail_id)))? - .try_into_move() - .ok_or_else(|| Error::UnexpectedApiResponse(format!("trail {} bcs content is not a move object", trail_id)))? - .deserialize() - .map_err(|e| Error::UnexpectedApiResponse(format!("failed to decode trail {} bcs data; {e}", trail_id))) -} - -/// Builds a trail transaction by auto-discovering the right capability for the -/// given owner and required permission via the trail's on-chain RoleMap. -pub(crate) async fn build_trail_transaction( - client: &C, - trail_id: ObjectID, - owner: IotaAddress, - permission: Permission, - method: impl AsRef, - additional_args: F, -) -> Result -where - F: FnOnce(&mut ProgrammableTransactionBuilder, &TypeTag) -> Result, Error>, - C: CoreClientReadOnly + OptionalSync, -{ - let trail = get_audit_trail(trail_id, client).await?; - let cap_ref = find_capable_cap(client, owner, trail_id, &trail, permission).await?; - build_trail_transaction_with_cap_ref(client, trail_id, cap_ref, method, additional_args).await -} - -/// Finds a capability owned by `owner` whose role has the required permission -/// according to the trail's RoleMap. -pub(crate) async fn find_capable_cap( - client: &C, - owner: IotaAddress, - trail_id: ObjectID, - trail: &OnChainAuditTrail, - permission: Permission, -) -> Result -where - C: CoreClientReadOnly + OptionalSync, -{ - let valid_roles: HashSet = trail - .roles - .roles - .iter() - .filter(|(_, role)| role.permissions.contains(&permission)) - .map(|(name, _)| name.clone()) - .collect(); - - let cap = find_owned_capability(client, owner, |cap| cap.matches_target_and_role(trail_id, &valid_roles)) - .await? - .ok_or_else(|| { - Error::InvalidArgument(format!( - "no capability with {:?} permission found for owner {owner} and trail {trail_id}", - permission - )) - })?; - - let object_id = *cap.id.object_id(); - utils::get_object_ref_by_id(client, &object_id).await -} - -pub(crate) async fn find_owned_capability( - client: &C, - owner: IotaAddress, - predicate: P, -) -> Result, Error> -where - C: CoreClientReadOnly + OptionalSync, - P: Fn(&Capability) -> bool + Send, -{ - let tf_components_package_id = client - .tf_components_package_id() - .expect("TfComponents package ID should be present for audit trail clients"); - let capability_struct_tag: StructTag = Capability::type_tag(tf_components_package_id) - .to_string() - .parse() - .expect("capability type tag is a valid struct tag"); - let query = IotaObjectResponseQuery::new( - Some(IotaObjectDataFilter::StructType(capability_struct_tag)), - Some(IotaObjectDataOptions::default().with_content()), - ); - - let mut cursor = None; - loop { - let mut page = client - .client_adapter() - .read_api() - .get_owned_objects(owner, Some(query.clone()), cursor, Some(25)) - .await - .map_err(|e| Error::RpcError(e.to_string()))?; - - let maybe_cap = std::mem::take(&mut page.data) - .into_iter() - .filter_map(|res| res.data) - .filter_map(|data| data.content) - .filter_map(|obj_data| { - let IotaParsedData::MoveObject(move_object) = obj_data else { - unreachable!() - }; - serde_json::from_value(move_object.fields.to_json_value()).ok() - }) - .find(&predicate); - cursor = page.next_cursor; - - if maybe_cap.is_some() { - return Ok(maybe_cap); - } - if !page.has_next_page { - break; - } - } - - Ok(None) -} - -pub(crate) async fn build_trail_transaction_with_cap_ref( - client: &C, - trail_id: ObjectID, - cap_ref: ObjectRef, - method: impl AsRef, - additional_args: F, -) -> Result -where - F: FnOnce(&mut ProgrammableTransactionBuilder, &TypeTag) -> Result, Error>, - C: CoreClientReadOnly + OptionalSync, -{ - let mut ptb = ProgrammableTransactionBuilder::new(); - - let type_tag = utils::get_type_tag(client, &trail_id).await?; - let tag = vec![type_tag.clone()]; - let trail_arg = utils::get_shared_object_arg(client, &trail_id, true).await?; - - let mut args = vec![ - ptb.obj(trail_arg) - .map_err(|e| Error::InvalidArgument(format!("Failed to create trail argument: {e}")))?, - ptb.obj(ObjectArg::ImmOrOwnedObject(cap_ref)) - .map_err(|e| Error::InvalidArgument(format!("Failed to create cap argument: {e}")))?, - ]; - - args.extend(additional_args(&mut ptb, &type_tag)?); - - let function = Identifier::from_str(method.as_ref()) - .map_err(|e| Error::InvalidArgument(format!("Invalid method name '{}': {e}", method.as_ref())))?; - - ptb.programmable_move_call(client.package_id(), ident_str!("main").into(), function, tag, args); - - Ok(ptb.finish()) -} - -pub(crate) async fn build_read_only_transaction( - client: &C, - trail_id: ObjectID, - method: impl AsRef, - additional_args: F, -) -> Result -where - F: FnOnce(&mut ProgrammableTransactionBuilder) -> Result, Error>, - C: CoreClientReadOnly + OptionalSync, -{ - let mut ptb = ProgrammableTransactionBuilder::new(); - - let tag = vec![utils::get_type_tag(client, &trail_id).await?]; - let trail_arg = utils::get_shared_object_arg(client, &trail_id, false).await?; - - let mut args = vec![ - ptb.obj(trail_arg) - .map_err(|e| Error::InvalidArgument(format!("Failed to create trail argument: {e}")))?, - ]; - - args.extend(additional_args(&mut ptb)?); - - let function = iota_interaction::types::Identifier::from_str(method.as_ref()) - .map_err(|e| Error::InvalidArgument(format!("Invalid method name '{}': {e}", method.as_ref())))?; - - ptb.programmable_move_call(client.package_id(), ident_str!("main").into(), function, tag, args); - - Ok(ptb.finish()) -} diff --git a/audit-trail-rs/src/core/records/mod.rs b/audit-trail-rs/src/core/records/mod.rs index a6c019f5..4a7876f0 100644 --- a/audit-trail-rs/src/core/records/mod.rs +++ b/audit-trail-rs/src/core/records/mod.rs @@ -3,17 +3,16 @@ use std::collections::{BTreeMap, HashMap}; -use iota_interaction::rpc_types::{IotaData as _, IotaObjectDataOptions}; use iota_interaction::types::TypeTag; use iota_interaction::types::base_types::ObjectID; -use iota_interaction::types::collection_types::{LinkedTable, LinkedTableNode}; -use iota_interaction::types::dynamic_field::{DynamicFieldName, Field}; -use iota_interaction::{IotaClientTrait, IotaKeySignature, OptionalSync}; +use iota_interaction::types::collection_types::LinkedTable; +use iota_interaction::{IotaKeySignature, OptionalSync}; use product_common::core_client::{CoreClient, CoreClientReadOnly}; use product_common::transaction::transaction_builder::TransactionBuilder; use secret_storage::Signer; use serde::de::DeserializeOwned; +use crate::core::internal::{linked_table, trail as trail_reader}; use crate::core::trail::{AuditTrailFull, AuditTrailReadOnly}; use crate::core::types::{Data, PaginatedRecord, Record}; use crate::error::Error; @@ -147,7 +146,7 @@ impl<'a, C, D> TrailRecords<'a, C, D> { where C: AuditTrailReadOnly, { - crate::core::operations::get_audit_trail(self.trail_id, self.client) + trail_reader::get_audit_trail(self.trail_id, self.client) .await .map(|on_chain_trail| on_chain_trail.records) } @@ -180,7 +179,7 @@ where ))); } - let node = fetch_linked_table_node::<_, V>(client, table.id, key).await?; + let node = linked_table::fetch_node::<_, u64, V>(client, table.id, &key, TypeTag::U64).await?; cursor = node.next; items.insert(key, node.value); @@ -224,53 +223,3 @@ where Ok(entries.into_iter().collect()) } - -async fn fetch_linked_table_node( - client: &C, - table_id: ObjectID, - key: u64, -) -> Result, Error> -where - C: CoreClientReadOnly + OptionalSync, - V: DeserializeOwned, -{ - let name = DynamicFieldName { - type_: TypeTag::U64, - value: serde_json::Value::String(key.to_string()), - }; - - let data = client - .client_adapter() - .read_api() - .get_dynamic_field_object_v2(table_id, name, Some(IotaObjectDataOptions::bcs_lossless())) - .await - .map_err(|err| Error::RpcError(err.to_string()))? - .data - .ok_or_else(|| { - Error::UnexpectedApiResponse(format!( - "dynamic-field object not found for linked-table id {table_id} and key {key}" - )) - })?; - - let field: Field> = data - .bcs - .ok_or_else(|| { - Error::UnexpectedApiResponse(format!( - "linked-table node {} missing bcs object content", - data.object_id - )) - })? - .try_into_move() - .ok_or_else(|| { - Error::UnexpectedApiResponse(format!( - "linked-table node {} bcs content is not a move object", - data.object_id - )) - })? - .deserialize() - .map_err(|err| { - Error::UnexpectedApiResponse(format!("failed to decode linked-table node {}; {err}", data.object_id)) - })?; - - Ok(field.value) -} diff --git a/audit-trail-rs/src/core/records/operations.rs b/audit-trail-rs/src/core/records/operations.rs index 184354ad..466a47d6 100644 --- a/audit-trail-rs/src/core/records/operations.rs +++ b/audit-trail-rs/src/core/records/operations.rs @@ -6,8 +6,8 @@ use iota_interaction::types::base_types::{IotaAddress, ObjectID}; use iota_interaction::types::transaction::ProgrammableTransaction; use product_common::core_client::CoreClientReadOnly; +use crate::core::internal::{capability, trail as trail_reader, tx}; use crate::core::types::{Data, OnChainAuditTrail, Permission}; -use crate::core::{operations, utils}; use crate::error::Error; pub(super) struct RecordsOps; @@ -25,7 +25,7 @@ impl RecordsOps { C: CoreClientReadOnly + OptionalSync, { if let Some(tag) = record_tag.clone() { - let trail = operations::get_audit_trail(trail_id, client).await?; + let trail = trail_reader::get_audit_trail(trail_id, client).await?; if !trail.tags.contains_key(&tag) { return Err(Error::InvalidArgument(format!( "record tag '{tag}' is not defined for trail {trail_id}" @@ -33,24 +33,18 @@ impl RecordsOps { } let cap_ref = find_capable_cap_for_tag(client, owner, trail_id, &trail, &tag).await?; - operations::build_trail_transaction_with_cap_ref( - client, - trail_id, - cap_ref, - "add_record", - |ptb, trail_tag| { - data.ensure_matches_tag(trail_tag)?; + tx::build_trail_transaction_with_cap_ref(client, trail_id, cap_ref, "add_record", |ptb, trail_tag| { + data.ensure_matches_tag(trail_tag)?; - let data_arg = data.into_ptb(ptb, "stored_data")?; - let metadata = utils::ptb_pure(ptb, "record_metadata", record_metadata)?; - let tag_arg = utils::ptb_pure(ptb, "record_tag", Some(tag))?; - let clock = utils::get_clock_ref(ptb); - Ok(vec![data_arg, metadata, tag_arg, clock]) - }, - ) + let data_arg = data.into_ptb(ptb, "stored_data")?; + let metadata = tx::ptb_pure(ptb, "record_metadata", record_metadata)?; + let tag_arg = tx::ptb_pure(ptb, "record_tag", Some(tag))?; + let clock = tx::get_clock_ref(ptb); + Ok(vec![data_arg, metadata, tag_arg, clock]) + }) .await } else { - operations::build_trail_transaction( + tx::build_trail_transaction( client, trail_id, owner, @@ -60,9 +54,9 @@ impl RecordsOps { data.ensure_matches_tag(trail_tag)?; let data_arg = data.into_ptb(ptb, "stored_data")?; - let metadata = utils::ptb_pure(ptb, "record_metadata", record_metadata)?; - let tag = utils::ptb_pure(ptb, "record_tag", Option::::None)?; - let clock = utils::get_clock_ref(ptb); + let metadata = tx::ptb_pure(ptb, "record_metadata", record_metadata)?; + let tag = tx::ptb_pure(ptb, "record_tag", Option::::None)?; + let clock = tx::get_clock_ref(ptb); Ok(vec![data_arg, metadata, tag, clock]) }, ) @@ -79,15 +73,15 @@ impl RecordsOps { where C: CoreClientReadOnly + OptionalSync, { - operations::build_trail_transaction( + tx::build_trail_transaction( client, trail_id, owner, Permission::DeleteRecord, "delete_record", |ptb, _| { - let seq = utils::ptb_pure(ptb, "sequence_number", sequence_number)?; - let clock = utils::get_clock_ref(ptb); + let seq = tx::ptb_pure(ptb, "sequence_number", sequence_number)?; + let clock = tx::get_clock_ref(ptb); Ok(vec![seq, clock]) }, ) @@ -103,15 +97,15 @@ impl RecordsOps { where C: CoreClientReadOnly + OptionalSync, { - operations::build_trail_transaction( + tx::build_trail_transaction( client, trail_id, owner, Permission::DeleteAllRecords, "delete_records_batch", |ptb, _| { - let limit_arg = utils::ptb_pure(ptb, "limit", limit)?; - let clock = utils::get_clock_ref(ptb); + let limit_arg = tx::ptb_pure(ptb, "limit", limit)?; + let clock = tx::get_clock_ref(ptb); Ok(vec![limit_arg, clock]) }, ) @@ -126,8 +120,8 @@ impl RecordsOps { where C: CoreClientReadOnly + OptionalSync, { - operations::build_read_only_transaction(client, trail_id, "get_record", |ptb| { - let seq = utils::ptb_pure(ptb, "sequence_number", sequence_number)?; + tx::build_read_only_transaction(client, trail_id, "get_record", |ptb| { + let seq = tx::ptb_pure(ptb, "sequence_number", sequence_number)?; Ok(vec![seq]) }) .await @@ -137,7 +131,7 @@ impl RecordsOps { where C: CoreClientReadOnly + OptionalSync, { - operations::build_read_only_transaction(client, trail_id, "record_count", |_| Ok(vec![])).await + tx::build_read_only_transaction(client, trail_id, "record_count", |_| Ok(vec![])).await } } @@ -162,7 +156,7 @@ where .map(|(name, _)| name.clone()) .collect::>(); - let cap = operations::find_owned_capability(client, owner, |cap| { + let cap = capability::find_owned_capability(client, owner, trail, |cap| { cap.target_key == trail_id && valid_roles.contains(&cap.role) }) .await? @@ -174,5 +168,5 @@ where })?; let object_id = *cap.id.object_id(); - utils::get_object_ref_by_id(client, &object_id).await + tx::get_object_ref_by_id(client, &object_id).await } diff --git a/audit-trail-rs/src/core/tags/operations.rs b/audit-trail-rs/src/core/tags/operations.rs index 5d63c8f3..57b8b7a3 100644 --- a/audit-trail-rs/src/core/tags/operations.rs +++ b/audit-trail-rs/src/core/tags/operations.rs @@ -6,8 +6,8 @@ use iota_interaction::types::base_types::{IotaAddress, ObjectID}; use iota_interaction::types::transaction::ProgrammableTransaction; use product_common::core_client::CoreClientReadOnly; +use crate::core::internal::tx; use crate::core::types::Permission; -use crate::core::{operations, utils}; use crate::error::Error; pub(super) struct TagsOps; @@ -22,15 +22,15 @@ impl TagsOps { where C: CoreClientReadOnly + OptionalSync, { - operations::build_trail_transaction( + tx::build_trail_transaction( client, trail_id, owner, Permission::AddRecordTags, "add_record_tag", |ptb, _| { - let tag_arg = utils::ptb_pure(ptb, "tag", tag)?; - let clock = utils::get_clock_ref(ptb); + let tag_arg = tx::ptb_pure(ptb, "tag", tag)?; + let clock = tx::get_clock_ref(ptb); Ok(vec![tag_arg, clock]) }, ) @@ -46,15 +46,15 @@ impl TagsOps { where C: CoreClientReadOnly + OptionalSync, { - operations::build_trail_transaction( + tx::build_trail_transaction( client, trail_id, owner, Permission::DeleteRecordTags, "remove_record_tag", |ptb, _| { - let tag_arg = utils::ptb_pure(ptb, "tag", tag)?; - let clock = utils::get_clock_ref(ptb); + let tag_arg = tx::ptb_pure(ptb, "tag", tag)?; + let clock = tx::get_clock_ref(ptb); Ok(vec![tag_arg, clock]) }, ) diff --git a/audit-trail-rs/src/core/trail.rs b/audit-trail-rs/src/core/trail.rs index 6d9f189e..00c84327 100644 --- a/audit-trail-rs/src/core/trail.rs +++ b/audit-trail-rs/src/core/trail.rs @@ -10,6 +10,7 @@ use secret_storage::Signer; use serde::de::DeserializeOwned; use crate::core::access::TrailAccess; +use crate::core::internal::trail as trail_reader; use crate::core::locking::TrailLocking; use crate::core::records::TrailRecords; use crate::core::tags::TrailTags; @@ -51,7 +52,7 @@ impl<'a, C> AuditTrailHandle<'a, C> { where C: AuditTrailReadOnly, { - crate::core::operations::get_audit_trail(self.trail_id, self.client).await + trail_reader::get_audit_trail(self.trail_id, self.client).await } /// Updates the trail's updatable metadata. diff --git a/audit-trail-rs/src/core/trail/operations.rs b/audit-trail-rs/src/core/trail/operations.rs index d5e9bca0..88db6049 100644 --- a/audit-trail-rs/src/core/trail/operations.rs +++ b/audit-trail-rs/src/core/trail/operations.rs @@ -6,8 +6,8 @@ use iota_interaction::types::base_types::{IotaAddress, ObjectID}; use iota_interaction::types::transaction::ProgrammableTransaction; use product_common::core_client::CoreClientReadOnly; +use crate::core::internal::tx; use crate::core::types::Permission; -use crate::core::{operations, utils}; use crate::error::Error; pub(super) struct TrailOps; @@ -21,8 +21,8 @@ impl TrailOps { where C: CoreClientReadOnly + OptionalSync, { - operations::build_trail_transaction(client, trail_id, owner, Permission::Migrate, "migrate", |ptb, _| { - let clock = utils::get_clock_ref(ptb); + tx::build_trail_transaction(client, trail_id, owner, Permission::Migrate, "migrate", |ptb, _| { + let clock = tx::get_clock_ref(ptb); Ok(vec![clock]) }) .await @@ -37,15 +37,15 @@ impl TrailOps { where C: CoreClientReadOnly + OptionalSync, { - operations::build_trail_transaction( + tx::build_trail_transaction( client, trail_id, owner, Permission::UpdateMetadata, "update_metadata", |ptb, _| { - let metadata_arg = utils::ptb_pure(ptb, "new_metadata", metadata)?; - let clock = utils::get_clock_ref(ptb); + let metadata_arg = tx::ptb_pure(ptb, "new_metadata", metadata)?; + let clock = tx::get_clock_ref(ptb); Ok(vec![metadata_arg, clock]) }, ) @@ -60,14 +60,14 @@ impl TrailOps { where C: CoreClientReadOnly + OptionalSync, { - operations::build_trail_transaction( + tx::build_trail_transaction( client, trail_id, owner, Permission::DeleteAuditTrail, "delete_audit_trail", |ptb, _| { - let clock = utils::get_clock_ref(ptb); + let clock = tx::get_clock_ref(ptb); Ok(vec![clock]) }, ) diff --git a/audit-trail-rs/src/core/types/audit_trail.rs b/audit-trail-rs/src/core/types/audit_trail.rs index 9c5b175e..06d62392 100644 --- a/audit-trail-rs/src/core/types/audit_trail.rs +++ b/audit-trail-rs/src/core/types/audit_trail.rs @@ -15,7 +15,8 @@ use serde::{Deserialize, Serialize}; use super::locking::LockingConfig; use super::role_map::RoleMap; -use crate::core::utils::{self, deserialize_vec_map}; +use crate::core::internal::move_collections::deserialize_vec_map; +use crate::core::internal::tx; use crate::error::Error; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -83,8 +84,8 @@ impl ImmutableMetadata { /// /// To be used when creating a new `ImmutableMetadata` object on the ledger. pub(in crate::core) fn to_ptb(&self, ptb: &mut Ptb, package_id: ObjectID) -> Result { - let name = utils::ptb_pure(ptb, "name", &self.name)?; - let description = utils::ptb_pure(ptb, "description", &self.description)?; + let name = tx::ptb_pure(ptb, "name", &self.name)?; + let description = tx::ptb_pure(ptb, "description", &self.description)?; Ok(ptb.programmable_move_call( package_id, diff --git a/audit-trail-rs/src/core/types/locking.rs b/audit-trail-rs/src/core/types/locking.rs index 845d65c3..1986c811 100644 --- a/audit-trail-rs/src/core/types/locking.rs +++ b/audit-trail-rs/src/core/types/locking.rs @@ -7,7 +7,7 @@ use iota_interaction::types::programmable_transaction_builder::ProgrammableTrans use iota_interaction::types::transaction::Argument; use serde::{Deserialize, Serialize}; -use crate::core::utils; +use crate::core::internal::tx; use crate::error::Error; /// Locking configuration for the audit trail. @@ -80,8 +80,8 @@ impl TimeLock { vec![], )), Self::UnlockAt(unix_time) => { - let unix_time = utils::ptb_pure(ptb, "unix_time", *unix_time)?; - let clock = utils::get_clock_ref(ptb); + let unix_time = tx::ptb_pure(ptb, "unix_time", *unix_time)?; + let clock = tx::get_clock_ref(ptb); Ok(ptb.programmable_move_call( package_id, @@ -92,8 +92,8 @@ impl TimeLock { )) } Self::UnlockAtMs(unix_time_ms) => { - let unix_time_ms = utils::ptb_pure(ptb, "unix_time_ms", *unix_time_ms)?; - let clock = utils::get_clock_ref(ptb); + let unix_time_ms = tx::ptb_pure(ptb, "unix_time_ms", *unix_time_ms)?; + let clock = tx::get_clock_ref(ptb); Ok(ptb.programmable_move_call( package_id, @@ -134,7 +134,7 @@ impl LockingWindow { vec![], )), Self::TimeBased { seconds } => { - let seconds = utils::ptb_pure(ptb, "seconds", *seconds)?; + let seconds = tx::ptb_pure(ptb, "seconds", *seconds)?; Ok(ptb.programmable_move_call( package_id, ident_str!("locking").into(), @@ -144,7 +144,7 @@ impl LockingWindow { )) } Self::CountBased { count } => { - let count = utils::ptb_pure(ptb, "count", *count)?; + let count = tx::ptb_pure(ptb, "count", *count)?; Ok(ptb.programmable_move_call( package_id, ident_str!("locking").into(), diff --git a/audit-trail-rs/src/core/types/record.rs b/audit-trail-rs/src/core/types/record.rs index a065698b..b02f85b7 100644 --- a/audit-trail-rs/src/core/types/record.rs +++ b/audit-trail-rs/src/core/types/record.rs @@ -11,7 +11,7 @@ use iota_interaction::types::transaction::Argument; use iota_interaction::types::{MOVE_STDLIB_PACKAGE_ID, TypeTag}; use serde::{Deserialize, Deserializer, Serialize}; -use crate::core::utils; +use crate::core::internal::tx; use crate::error::Error; /// Page of records loaded through linked-table traversal. @@ -59,8 +59,8 @@ impl InitialRecord { pub(in crate::core) fn into_ptb(self, ptb: &mut Ptb, package_id: ObjectID) -> Result { let data_tag = self.data.tag(); let data = self.data.into_ptb(ptb, "initial_record_data")?; - let metadata = utils::ptb_pure(ptb, "initial_record_metadata", self.metadata)?; - let tag = utils::ptb_pure(ptb, "initial_record_tag", self.tag)?; + let metadata = tx::ptb_pure(ptb, "initial_record_metadata", self.metadata)?; + let tag = tx::ptb_pure(ptb, "initial_record_tag", self.tag)?; Ok(ptb.programmable_move_call( package_id, @@ -135,8 +135,8 @@ impl Data { /// Creates a PTB argument for `D` where `D` is the concrete Move data type. pub(in crate::core) fn into_ptb(self, ptb: &mut Ptb, name: &str) -> Result { match self { - Data::Bytes(bytes) => utils::ptb_pure(ptb, name, bytes), - Data::Text(text) => utils::ptb_pure(ptb, name, text), + Data::Bytes(bytes) => tx::ptb_pure(ptb, name, bytes), + Data::Text(text) => tx::ptb_pure(ptb, name, text), } } diff --git a/audit-trail-rs/src/core/types/role_map.rs b/audit-trail-rs/src/core/types/role_map.rs index 264d6ea7..a0b241fe 100644 --- a/audit-trail-rs/src/core/types/role_map.rs +++ b/audit-trail-rs/src/core/types/role_map.rs @@ -14,8 +14,8 @@ use iota_interaction::{MoveType, ident_str}; use serde::{Deserialize, Serialize}; use super::permission::Permission; -use crate::core::utils; -use crate::core::utils::{deserialize_vec_map, deserialize_vec_set}; +use crate::core::internal::move_collections::{deserialize_vec_map, deserialize_vec_set}; +use crate::core::internal::tx; use crate::error::Error; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RoleMap { @@ -92,7 +92,7 @@ impl RoleTags { pub(in crate::core) fn to_ptb(&self, ptb: &mut Ptb, package_id: ObjectID) -> Result { let mut tags = self.tags.iter().cloned().collect::>(); tags.sort(); - let tags_arg = utils::ptb_pure(ptb, "tags", tags)?; + let tags_arg = tx::ptb_pure(ptb, "tags", tags)?; Ok(ptb.programmable_move_call( package_id, diff --git a/audit-trail-rs/tests/e2e/records.rs b/audit-trail-rs/tests/e2e/records.rs index eb1f14e9..a573aad9 100644 --- a/audit-trail-rs/tests/e2e/records.rs +++ b/audit-trail-rs/tests/e2e/records.rs @@ -172,6 +172,121 @@ async fn add_tagged_record_requires_trail_defined_tag() -> anyhow::Result<()> { Ok(()) } +#[tokio::test] +async fn add_record_skips_revoked_capability_when_valid_one_exists() -> anyhow::Result<()> { + let admin = get_funded_test_client().await?; + let writer = get_funded_test_client().await?; + let trail_id = admin.create_test_trail(Data::text("records-revoked-selector")).await?; + let records = writer.trail(trail_id).records(); + let role_name = "RecordWriter"; + + admin + .create_role(trail_id, role_name, [Permission::AddRecord], None) + .await?; + let stale_cap = admin + .issue_cap( + trail_id, + role_name, + CapabilityIssueOptions { + issued_to: Some(writer.sender_address()), + ..CapabilityIssueOptions::default() + }, + ) + .await?; + + admin + .trail(trail_id) + .access() + .revoke_capability(stale_cap.capability_id, stale_cap.valid_until) + .build_and_execute(&admin) + .await?; + + admin + .issue_cap( + trail_id, + role_name, + CapabilityIssueOptions { + issued_to: Some(writer.sender_address()), + ..CapabilityIssueOptions::default() + }, + ) + .await?; + + let added = records + .add(Data::text("writer record"), None, None) + .build_and_execute(&writer) + .await? + .output; + + assert_eq!(added.sequence_number, 1); + assert_text_data(records.get(1).await?.data, "writer record"); + + Ok(()) +} + +#[tokio::test] +async fn add_tagged_record_skips_revoked_capability_when_valid_one_exists() -> anyhow::Result<()> { + let admin = get_funded_test_client().await?; + let writer = get_funded_test_client().await?; + let trail_id = admin + .create_test_trail_with_tags(Data::text("records-revoked-tagged"), ["finance"]) + .await?; + let records = writer.trail(trail_id).records(); + let role_name = "TaggedWriter"; + admin + .create_role( + trail_id, + role_name, + [Permission::AddRecord], + Some(RoleTags::new(["finance"])), + ) + .await?; + + let stale_cap = admin + .issue_cap( + trail_id, + role_name, + CapabilityIssueOptions { + issued_to: Some(writer.sender_address()), + ..CapabilityIssueOptions::default() + }, + ) + .await?; + + admin + .trail(trail_id) + .access() + .revoke_capability(stale_cap.capability_id, stale_cap.valid_until) + .build_and_execute(&admin) + .await?; + + admin + .issue_cap( + trail_id, + role_name, + CapabilityIssueOptions { + issued_to: Some(writer.sender_address()), + ..CapabilityIssueOptions::default() + }, + ) + .await?; + + let added = records + .add( + Data::text("finance entry"), + Some("tagged".to_string()), + Some("finance".to_string()), + ) + .build_and_execute(&writer) + .await? + .output; + + assert_eq!(added.sequence_number, 1); + assert_eq!(records.get(1).await?.tag, Some("finance".to_string())); + + Ok(()) +} + #[tokio::test] async fn add_record_rejects_mismatched_data_type() -> anyhow::Result<()> { let client = get_funded_test_client().await?; From ccf12009ad63053cf37b5440ee6d2bbcfa059a83 Mon Sep 17 00:00:00 2001 From: Yasir Date: Tue, 31 Mar 2026 17:07:56 +0300 Subject: [PATCH 116/189] fix audit trail data model and empty create --- audit-trail-rs/src/core/create/operations.rs | 22 +-- audit-trail-rs/src/core/records/operations.rs | 9 +- audit-trail-rs/src/core/types/record.rs | 130 +++++++----------- audit-trail-rs/tests/e2e/records.rs | 15 +- audit-trail-rs/tests/e2e/trail.rs | 25 ++++ 5 files changed, 96 insertions(+), 105 deletions(-) diff --git a/audit-trail-rs/src/core/create/operations.rs b/audit-trail-rs/src/core/create/operations.rs index 87b457dd..d330e0b1 100644 --- a/audit-trail-rs/src/core/create/operations.rs +++ b/audit-trail-rs/src/core/create/operations.rs @@ -9,7 +9,7 @@ use iota_interaction::types::programmable_transaction_builder::ProgrammableTrans use iota_interaction::types::transaction::{Argument, ProgrammableTransaction}; use crate::core::internal::tx; -use crate::core::types::{ImmutableMetadata, InitialRecord, LockingConfig}; +use crate::core::types::{Data, ImmutableMetadata, InitialRecord, LockingConfig}; use crate::error::Error; pub(super) struct CreateOps; @@ -39,16 +39,16 @@ impl CreateOps { record_tags, } = args; - let initial_record = initial_record.ok_or_else(|| { - Error::InvalidArgument( - "initial_record is required to infer trail record type; use `with_initial_record(...)`".to_string(), - ) - })?; - let data_tag = initial_record.data.tag(); - let initial_record_tag = InitialRecord::tag(audit_trail_package_id, &data_tag); - let initial_record_arg = initial_record.into_ptb(&mut ptb, audit_trail_package_id)?; - let initial_record = tx::option_to_move(Some(initial_record_arg), initial_record_tag, &mut ptb) - .map_err(|e| Error::InvalidArgument(format!("failed to build initial_record option: {e}")))?; + let data_tag = Data::tag(audit_trail_package_id); + let initial_record_tag = InitialRecord::tag(audit_trail_package_id); + let initial_record = match initial_record { + Some(initial_record) => { + let initial_record_arg = initial_record.into_ptb(&mut ptb, audit_trail_package_id)?; + tx::option_to_move(Some(initial_record_arg), initial_record_tag, &mut ptb) + } + None => tx::option_to_move(None, initial_record_tag, &mut ptb), + } + .map_err(|e| Error::InvalidArgument(format!("failed to build initial_record option: {e}")))?; let locking_config = locking_config.to_ptb(&mut ptb, audit_trail_package_id, tf_components_package_id)?; let immutable_metadata_tag = ImmutableMetadata::tag(audit_trail_package_id); diff --git a/audit-trail-rs/src/core/records/operations.rs b/audit-trail-rs/src/core/records/operations.rs index 466a47d6..84df9a9c 100644 --- a/audit-trail-rs/src/core/records/operations.rs +++ b/audit-trail-rs/src/core/records/operations.rs @@ -24,6 +24,7 @@ impl RecordsOps { where C: CoreClientReadOnly + OptionalSync, { + let package_id = client.package_id(); if let Some(tag) = record_tag.clone() { let trail = trail_reader::get_audit_trail(trail_id, client).await?; if !trail.tags.contains_key(&tag) { @@ -34,9 +35,9 @@ impl RecordsOps { let cap_ref = find_capable_cap_for_tag(client, owner, trail_id, &trail, &tag).await?; tx::build_trail_transaction_with_cap_ref(client, trail_id, cap_ref, "add_record", |ptb, trail_tag| { - data.ensure_matches_tag(trail_tag)?; + data.ensure_matches_tag(trail_tag, package_id)?; - let data_arg = data.into_ptb(ptb, "stored_data")?; + let data_arg = data.into_ptb(ptb, package_id)?; let metadata = tx::ptb_pure(ptb, "record_metadata", record_metadata)?; let tag_arg = tx::ptb_pure(ptb, "record_tag", Some(tag))?; let clock = tx::get_clock_ref(ptb); @@ -51,9 +52,9 @@ impl RecordsOps { Permission::AddRecord, "add_record", |ptb, trail_tag| { - data.ensure_matches_tag(trail_tag)?; + data.ensure_matches_tag(trail_tag, package_id)?; - let data_arg = data.into_ptb(ptb, "stored_data")?; + let data_arg = data.into_ptb(ptb, package_id)?; let metadata = tx::ptb_pure(ptb, "record_metadata", record_metadata)?; let tag = tx::ptb_pure(ptb, "record_tag", Option::::None)?; let clock = tx::get_clock_ref(ptb); diff --git a/audit-trail-rs/src/core/types/record.rs b/audit-trail-rs/src/core/types/record.rs index b02f85b7..e7fad537 100644 --- a/audit-trail-rs/src/core/types/record.rs +++ b/audit-trail-rs/src/core/types/record.rs @@ -8,8 +8,8 @@ use iota_interaction::ident_str; use iota_interaction::types::base_types::{IotaAddress, ObjectID}; use iota_interaction::types::programmable_transaction_builder::ProgrammableTransactionBuilder as Ptb; use iota_interaction::types::transaction::Argument; -use iota_interaction::types::{MOVE_STDLIB_PACKAGE_ID, TypeTag}; -use serde::{Deserialize, Deserializer, Serialize}; +use iota_interaction::types::TypeTag; +use serde::{Deserialize, Serialize}; use crate::core::internal::tx; use crate::error::Error; @@ -51,14 +51,17 @@ impl InitialRecord { } } - pub(crate) fn tag(package_id: ObjectID, data_tag: &TypeTag) -> TypeTag { - TypeTag::from_str(&format!("{package_id}::record::InitialRecord<{data_tag}>")) + pub(crate) fn tag(package_id: ObjectID) -> TypeTag { + TypeTag::from_str(&format!( + "{package_id}::record::InitialRecord<{}>", + Data::tag(package_id) + )) .expect("invalid TypeTag for InitialRecord") } pub(in crate::core) fn into_ptb(self, ptb: &mut Ptb, package_id: ObjectID) -> Result { - let data_tag = self.data.tag(); - let data = self.data.into_ptb(ptb, "initial_record_data")?; + let data_tag = Data::tag(package_id); + let data = self.data.into_ptb(ptb, package_id)?; let metadata = tx::ptb_pure(ptb, "initial_record_metadata", self.metadata)?; let tag = tx::ptb_pure(ptb, "initial_record_tag", self.tag)?; @@ -97,59 +100,54 @@ impl RecordCorrection { } /// Supported record data types. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum Data { Bytes(Vec), Text(String), } -impl<'de> Deserialize<'de> for Data { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let bytes = Vec::::deserialize(deserializer)?; - - if let Ok(text) = String::from_utf8(bytes.clone()) { - if text.chars().all(|c| c.is_ascii_graphic() || c.is_ascii_whitespace()) { - Ok(Data::Text(text)) - } else { - Ok(Data::Bytes(bytes)) - } - } else { - Ok(Data::Bytes(bytes)) - } - } -} - impl Data { - /// Returns the Move type tag for this data type. - pub(crate) fn tag(&self) -> TypeTag { - match self { - Data::Bytes(_) => TypeTag::Vector(Box::new(TypeTag::U8)), - Data::Text(_) => TypeTag::from_str(&format!("{MOVE_STDLIB_PACKAGE_ID}::string::String")) - .expect("should be valid type tag"), - } + /// Returns the Move type tag for `record::Data`. + pub(crate) fn tag(package_id: ObjectID) -> TypeTag { + TypeTag::from_str(&format!("{package_id}::record::Data")).expect("should be valid type tag") } - /// Creates a PTB argument for `D` where `D` is the concrete Move data type. - pub(in crate::core) fn into_ptb(self, ptb: &mut Ptb, name: &str) -> Result { + /// Creates a PTB argument for `record::Data`. + pub(in crate::core) fn into_ptb(self, ptb: &mut Ptb, package_id: ObjectID) -> Result { match self { - Data::Bytes(bytes) => tx::ptb_pure(ptb, name, bytes), - Data::Text(text) => tx::ptb_pure(ptb, name, text), + Data::Bytes(bytes) => { + let bytes = tx::ptb_pure(ptb, "data_bytes", bytes)?; + Ok(ptb.programmable_move_call( + package_id, + ident_str!("record").into(), + ident_str!("new_bytes").into(), + vec![], + vec![bytes], + )) + } + Data::Text(text) => { + let text = tx::ptb_pure(ptb, "data_text", text)?; + Ok(ptb.programmable_move_call( + package_id, + ident_str!("record").into(), + ident_str!("new_text").into(), + vec![], + vec![text], + )) + } } } - /// Validates that this data payload matches the on-chain trail data type. - pub(in crate::core) fn ensure_matches_tag(&self, expected: &TypeTag) -> Result<(), Error> { - let actual = self.tag(); + /// Validates that the on-chain trail stores `record::Data`. + pub(in crate::core) fn ensure_matches_tag(&self, expected: &TypeTag, package_id: ObjectID) -> Result<(), Error> { + let actual = Self::tag(package_id); if &actual == expected { Ok(()) } else { Err(Error::InvalidArgument(format!( - "record data type mismatch: provided {:?}, trail expects {:?}", - actual, expected + "record data type mismatch: trail expects {:?}, SDK writes {:?}", + expected, actual ))) } } @@ -217,50 +215,18 @@ impl From<&[u8]> for Data { mod tests { use super::Data; - fn deserialize_from_raw_bytes(payload: Vec) -> Data { - let encoded = bcs::to_bytes(&payload).expect("failed to bcs encode bytes payload"); - bcs::from_bytes::(&encoded).expect("failed to deserialize Data from bcs payload") - } - #[test] - fn deserialize_ascii_text_returns_text_variant() { - let data = deserialize_from_raw_bytes(b"hello world".to_vec()); + fn data_bcs_roundtrip_preserves_text_variant() { + let encoded = bcs::to_bytes(&Data::Text("hello world".to_string())).expect("failed to encode Data"); + let data = bcs::from_bytes::(&encoded).expect("failed to decode Data"); assert_eq!(data, Data::Text("hello world".to_string())); } #[test] - fn deserialize_ascii_text_with_whitespace_returns_text_variant() { - let data = deserialize_from_raw_bytes(b"line 1\nline 2\tend".to_vec()); - assert_eq!(data, Data::Text("line 1\nline 2\tend".to_string())); - } - - #[test] - fn deserialize_non_ascii_utf8_returns_bytes_variant() { - let data = deserialize_from_raw_bytes("olá mundo".as_bytes().to_vec()); - assert_eq!(data, Data::Bytes("olá mundo".as_bytes().to_vec())); - } - - #[test] - fn deserialize_ascii_like_binary_returns_text_variant() { - let data = deserialize_from_raw_bytes(b"GIF89a".to_vec()); - assert_eq!(data, Data::Text("GIF89a".to_string())); - } - - #[test] - fn deserialize_utf8_with_control_chars_returns_bytes_variant() { - let data = deserialize_from_raw_bytes(vec![b'a', b'b', 0x00, b'c']); - assert_eq!(data, Data::Bytes(vec![b'a', b'b', 0x00, b'c'])); - } - - #[test] - fn deserialize_invalid_utf8_returns_bytes_variant() { - let data = deserialize_from_raw_bytes(vec![0xF0, 0x28, 0x8C, 0x28]); - assert_eq!(data, Data::Bytes(vec![0xF0, 0x28, 0x8C, 0x28])); - } - - #[test] - fn deserialize_empty_payload_returns_empty_text() { - let data = deserialize_from_raw_bytes(Vec::new()); - assert_eq!(data, Data::Text(String::new())); + fn data_bcs_roundtrip_preserves_bytes_variant() { + let encoded = bcs::to_bytes(&Data::Bytes(vec![0x47, 0x49, 0x46, 0x38, 0x39, 0x61])) + .expect("failed to encode Data"); + let data = bcs::from_bytes::(&encoded).expect("failed to decode Data"); + assert_eq!(data, Data::Bytes(vec![0x47, 0x49, 0x46, 0x38, 0x39, 0x61])); } } diff --git a/audit-trail-rs/tests/e2e/records.rs b/audit-trail-rs/tests/e2e/records.rs index a573aad9..7aeddbe3 100644 --- a/audit-trail-rs/tests/e2e/records.rs +++ b/audit-trail-rs/tests/e2e/records.rs @@ -288,27 +288,26 @@ async fn add_tagged_record_skips_revoked_capability_when_valid_one_exists() -> a } #[tokio::test] -async fn add_record_rejects_mismatched_data_type() -> anyhow::Result<()> { +async fn add_record_allows_mixed_data_variants() -> anyhow::Result<()> { let client = get_funded_test_client().await?; let trail_id = client.create_test_trail(Data::text("text-trail")).await?; let records = client.trail(trail_id).records(); grant_role_capability(&client, trail_id, "RecordWriter", [Permission::AddRecord]).await?; - let add_mismatch = records + let added = records .add( Data::bytes(vec![0xFF, 0x00, 0xAA]), Some("binary payload".to_string()), None, ) .build_and_execute(&client) - .await; + .await? + .output; - assert!( - add_mismatch.is_err(), - "adding bytes to a text trail should fail before execution" - ); - assert_eq!(records.record_count().await?, 1); + assert_eq!(added.sequence_number, 1); + assert_eq!(records.record_count().await?, 2); + assert_bytes_data(records.get(1).await?.data, &[0xFF, 0x00, 0xAA]); Ok(()) } diff --git a/audit-trail-rs/tests/e2e/trail.rs b/audit-trail-rs/tests/e2e/trail.rs index b5baea04..176e8909 100644 --- a/audit-trail-rs/tests/e2e/trail.rs +++ b/audit-trail-rs/tests/e2e/trail.rs @@ -43,6 +43,31 @@ async fn create_trail_with_default_builder_settings() -> anyhow::Result<()> { Ok(()) } +#[tokio::test] +async fn create_empty_trail_with_default_builder_settings() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + + let created = client + .create_trail() + .finish() + .build_and_execute(&client) + .await? + .output; + + assert_eq!(created.creator, client.sender_address()); + + let on_chain = created.fetch_audit_trail(&client).await?; + assert_eq!(on_chain.id.object_id(), &created.trail_id); + assert_eq!(on_chain.creator, client.sender_address()); + assert_eq!(on_chain.sequence_number, 0); + assert_eq!(on_chain.locking_config, config_with_window(LockingWindow::None)); + assert!(on_chain.immutable_metadata.is_none()); + assert!(on_chain.updatable_metadata.is_none()); + assert_eq!(client.trail(created.trail_id).records().record_count().await?, 0); + + Ok(()) +} + #[tokio::test] async fn create_trail_with_metadata_and_time_lock() -> anyhow::Result<()> { let client = get_funded_test_client().await?; From 7fe1e8bf530b1dfe3499f6d59e02c62ae63cbda5 Mon Sep 17 00:00:00 2001 From: Yasir Date: Wed, 1 Apr 2026 08:17:18 +0300 Subject: [PATCH 117/189] fix dynamic field pagination lookup --- .../src/core/internal/capability.rs | 47 +++++++++++--- .../src/core/internal/linked_table.rs | 61 ++----------------- audit-trail-rs/src/core/records/mod.rs | 13 +++- 3 files changed, 57 insertions(+), 64 deletions(-) diff --git a/audit-trail-rs/src/core/internal/capability.rs b/audit-trail-rs/src/core/internal/capability.rs index bb5fc729..decb4dcb 100644 --- a/audit-trail-rs/src/core/internal/capability.rs +++ b/audit-trail-rs/src/core/internal/capability.rs @@ -1,8 +1,9 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use std::collections::HashSet; +use std::collections::{BTreeMap, HashSet}; +use iota_interaction::rpc_types::{IotaMoveStruct, IotaMoveValue}; use iota_interaction::move_types::language_storage::StructTag; use iota_interaction::rpc_types::{ IotaObjectDataFilter, IotaObjectDataOptions, IotaObjectResponseQuery, IotaParsedData, @@ -10,6 +11,7 @@ use iota_interaction::rpc_types::{ use iota_interaction::types::TypeTag; use iota_interaction::types::base_types::{IotaAddress, ObjectID, ObjectRef}; use iota_interaction::types::id::ID; +use iota_interaction::types::dynamic_field::DynamicFieldName; use iota_interaction::{IotaClientTrait, OptionalSync}; use product_common::core_client::CoreClientReadOnly; @@ -111,12 +113,43 @@ async fn revoked_capability_ids(client: &C, trail: &OnChainAuditTrail) -> Res where C: CoreClientReadOnly + OptionalSync, { - linked_table::collect_keys::<_, ObjectID, u64>( - client, - &trail.roles.revoked_capabilities, - TypeTag::Struct(Box::new(ID::type_())), - ) - .await + let table = &trail.roles.revoked_capabilities; + let expected = table.size as usize; + let mut cursor = table.head; + let mut keys = HashSet::with_capacity(expected); + + while let Some(key) = cursor { + if !keys.insert(key) { + return Err(Error::UnexpectedApiResponse(format!( + "cycle detected while traversing linked-table {table_id}; repeated key {key}", + table_id = table.id + ))); + } + + let node = linked_table::fetch_node::<_, ObjectID, u64>( + client, + table.id, + DynamicFieldName { + type_: TypeTag::Struct(Box::new(ID::type_())), + value: IotaMoveStruct::WithFields(BTreeMap::from([( + "bytes".to_string(), + IotaMoveValue::Address(IotaAddress::from(key)), + )])) + .to_json_value(), + }, + ) + .await?; + cursor = node.next; + } + + if keys.len() != expected { + return Err(Error::UnexpectedApiResponse(format!( + "linked-table traversal mismatch; expected {expected} entries, got {}", + keys.len() + ))); + } + + Ok(keys) } fn capability_matches

( diff --git a/audit-trail-rs/src/core/internal/linked_table.rs b/audit-trail-rs/src/core/internal/linked_table.rs index 9588e99a..2ec47849 100644 --- a/audit-trail-rs/src/core/internal/linked_table.rs +++ b/audit-trail-rs/src/core/internal/linked_table.rs @@ -1,78 +1,27 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use std::collections::HashSet; -use std::fmt::Display; -use std::hash::Hash; - use iota_interaction::rpc_types::{IotaData as _, IotaObjectDataOptions}; -use iota_interaction::types::TypeTag; use iota_interaction::types::base_types::ObjectID; -use iota_interaction::types::collection_types::{LinkedTable, LinkedTableNode}; +use iota_interaction::types::collection_types::LinkedTableNode; use iota_interaction::types::dynamic_field::{DynamicFieldName, Field}; use iota_interaction::{IotaClientTrait, OptionalSync}; use product_common::core_client::CoreClientReadOnly; -use serde::Serialize; use serde::de::DeserializeOwned; use crate::error::Error; -pub(crate) async fn collect_keys( - client: &C, - table: &LinkedTable, - key_type: TypeTag, -) -> Result, Error> -where - C: CoreClientReadOnly + OptionalSync, - K: Clone + DeserializeOwned + Display + Eq + Hash + Serialize, - V: DeserializeOwned, -{ - let expected = table.size as usize; - let mut cursor = table.head.clone(); - let mut keys = HashSet::with_capacity(expected); - - while let Some(key) = cursor { - if !keys.insert(key.clone()) { - return Err(Error::UnexpectedApiResponse(format!( - "cycle detected while traversing linked-table {table_id}; repeated key {key}", - table_id = table.id - ))); - } - - let node = fetch_node::<_, K, V>(client, table.id, &key, key_type.clone()).await?; - cursor = node.next; - } - - if keys.len() != expected { - return Err(Error::UnexpectedApiResponse(format!( - "linked-table traversal mismatch; expected {expected} entries, got {}", - keys.len() - ))); - } - - Ok(keys) -} - pub(crate) async fn fetch_node( client: &C, table_id: ObjectID, - key: &K, - key_type: TypeTag, + name: DynamicFieldName, ) -> Result, Error> where C: CoreClientReadOnly + OptionalSync, - K: Clone + DeserializeOwned + Display + Serialize, + K: DeserializeOwned, V: DeserializeOwned, { - let name = DynamicFieldName { - type_: key_type, - value: serde_json::to_value(key).map_err(|err| { - Error::UnexpectedApiResponse(format!( - "failed to encode linked-table dynamic-field key {key} for table {table_id}; {err}" - )) - })?, - }; - + let name_display = name.to_string(); let data = client .client_adapter() .read_api() @@ -82,7 +31,7 @@ where .data .ok_or_else(|| { Error::UnexpectedApiResponse(format!( - "dynamic-field object not found for linked-table id {table_id} and key {key}" + "dynamic-field object not found for linked-table id {table_id} and name {name_display}" )) })?; diff --git a/audit-trail-rs/src/core/records/mod.rs b/audit-trail-rs/src/core/records/mod.rs index 4a7876f0..53a93d4c 100644 --- a/audit-trail-rs/src/core/records/mod.rs +++ b/audit-trail-rs/src/core/records/mod.rs @@ -3,9 +3,12 @@ use std::collections::{BTreeMap, HashMap}; +use iota_interaction::move_core_types::annotated_value::MoveValue; +use iota_interaction::rpc_types::IotaMoveValue; use iota_interaction::types::TypeTag; use iota_interaction::types::base_types::ObjectID; use iota_interaction::types::collection_types::LinkedTable; +use iota_interaction::types::dynamic_field::DynamicFieldName; use iota_interaction::{IotaKeySignature, OptionalSync}; use product_common::core_client::{CoreClient, CoreClientReadOnly}; use product_common::transaction::transaction_builder::TransactionBuilder; @@ -179,7 +182,15 @@ where ))); } - let node = linked_table::fetch_node::<_, u64, V>(client, table.id, &key, TypeTag::U64).await?; + let node = linked_table::fetch_node::<_, u64, V>( + client, + table.id, + DynamicFieldName { + type_: TypeTag::U64, + value: IotaMoveValue::from(MoveValue::U64(key)).to_json_value(), + }, + ) + .await?; cursor = node.next; items.insert(key, node.value); From 717241f271d3087e7cb1967325f1db1ce111751e Mon Sep 17 00:00:00 2001 From: Yasir Date: Wed, 1 Apr 2026 09:53:30 +0300 Subject: [PATCH 118/189] chore: clean up audit trail formatting --- audit-trail-rs/src/core/internal/capability.rs | 8 +++----- audit-trail-rs/src/core/internal/trail.rs | 2 +- audit-trail-rs/src/core/internal/tx.rs | 3 +-- audit-trail-rs/src/core/types/record.rs | 8 ++++---- audit-trail-rs/tests/e2e/trail.rs | 7 +------ 5 files changed, 10 insertions(+), 18 deletions(-) diff --git a/audit-trail-rs/src/core/internal/capability.rs b/audit-trail-rs/src/core/internal/capability.rs index decb4dcb..440a043d 100644 --- a/audit-trail-rs/src/core/internal/capability.rs +++ b/audit-trail-rs/src/core/internal/capability.rs @@ -3,20 +3,18 @@ use std::collections::{BTreeMap, HashSet}; -use iota_interaction::rpc_types::{IotaMoveStruct, IotaMoveValue}; use iota_interaction::move_types::language_storage::StructTag; use iota_interaction::rpc_types::{ - IotaObjectDataFilter, IotaObjectDataOptions, IotaObjectResponseQuery, IotaParsedData, + IotaMoveStruct, IotaMoveValue, IotaObjectDataFilter, IotaObjectDataOptions, IotaObjectResponseQuery, IotaParsedData, }; use iota_interaction::types::TypeTag; use iota_interaction::types::base_types::{IotaAddress, ObjectID, ObjectRef}; -use iota_interaction::types::id::ID; use iota_interaction::types::dynamic_field::DynamicFieldName; +use iota_interaction::types::id::ID; use iota_interaction::{IotaClientTrait, OptionalSync}; use product_common::core_client::CoreClientReadOnly; -use super::linked_table; -use super::tx; +use super::{linked_table, tx}; use crate::core::types::{Capability, OnChainAuditTrail, Permission}; use crate::error::Error; diff --git a/audit-trail-rs/src/core/internal/trail.rs b/audit-trail-rs/src/core/internal/trail.rs index 7c898013..cd1ddee2 100644 --- a/audit-trail-rs/src/core/internal/trail.rs +++ b/audit-trail-rs/src/core/internal/trail.rs @@ -2,12 +2,12 @@ // SPDX-License-Identifier: Apache-2.0 use iota_interaction::rpc_types::{IotaData as _, IotaObjectDataOptions}; +use iota_interaction::types::base_types::ObjectID; use iota_interaction::{IotaClientTrait, OptionalSync}; use product_common::core_client::CoreClientReadOnly; use crate::core::types::OnChainAuditTrail; use crate::error::Error; -use iota_interaction::types::base_types::ObjectID; pub(crate) async fn get_audit_trail(trail_id: ObjectID, client: &C) -> Result where diff --git a/audit-trail-rs/src/core/internal/tx.rs b/audit-trail-rs/src/core/internal/tx.rs index 2d0075ff..d379536d 100644 --- a/audit-trail-rs/src/core/internal/tx.rs +++ b/audit-trail-rs/src/core/internal/tx.rs @@ -17,8 +17,7 @@ use iota_interaction::{IotaClientTrait, OptionalSync, ident_str}; use product_common::core_client::CoreClientReadOnly; use serde::Serialize; -use super::capability; -use super::trail as trail_reader; +use super::{capability, trail as trail_reader}; use crate::core::types::Permission; use crate::error::Error; diff --git a/audit-trail-rs/src/core/types/record.rs b/audit-trail-rs/src/core/types/record.rs index e7fad537..d20f743d 100644 --- a/audit-trail-rs/src/core/types/record.rs +++ b/audit-trail-rs/src/core/types/record.rs @@ -5,10 +5,10 @@ use std::collections::{BTreeMap, HashSet}; use std::str::FromStr; use iota_interaction::ident_str; +use iota_interaction::types::TypeTag; use iota_interaction::types::base_types::{IotaAddress, ObjectID}; use iota_interaction::types::programmable_transaction_builder::ProgrammableTransactionBuilder as Ptb; use iota_interaction::types::transaction::Argument; -use iota_interaction::types::TypeTag; use serde::{Deserialize, Serialize}; use crate::core::internal::tx; @@ -56,7 +56,7 @@ impl InitialRecord { "{package_id}::record::InitialRecord<{}>", Data::tag(package_id) )) - .expect("invalid TypeTag for InitialRecord") + .expect("invalid TypeTag for InitialRecord") } pub(in crate::core) fn into_ptb(self, ptb: &mut Ptb, package_id: ObjectID) -> Result { @@ -224,8 +224,8 @@ mod tests { #[test] fn data_bcs_roundtrip_preserves_bytes_variant() { - let encoded = bcs::to_bytes(&Data::Bytes(vec![0x47, 0x49, 0x46, 0x38, 0x39, 0x61])) - .expect("failed to encode Data"); + let encoded = + bcs::to_bytes(&Data::Bytes(vec![0x47, 0x49, 0x46, 0x38, 0x39, 0x61])).expect("failed to encode Data"); let data = bcs::from_bytes::(&encoded).expect("failed to decode Data"); assert_eq!(data, Data::Bytes(vec![0x47, 0x49, 0x46, 0x38, 0x39, 0x61])); } diff --git a/audit-trail-rs/tests/e2e/trail.rs b/audit-trail-rs/tests/e2e/trail.rs index 176e8909..5506416d 100644 --- a/audit-trail-rs/tests/e2e/trail.rs +++ b/audit-trail-rs/tests/e2e/trail.rs @@ -47,12 +47,7 @@ async fn create_trail_with_default_builder_settings() -> anyhow::Result<()> { async fn create_empty_trail_with_default_builder_settings() -> anyhow::Result<()> { let client = get_funded_test_client().await?; - let created = client - .create_trail() - .finish() - .build_and_execute(&client) - .await? - .output; + let created = client.create_trail().finish().build_and_execute(&client).await?.output; assert_eq!(created.creator, client.sender_address()); From 0001163d00ab46695b404bfa5c27845326e043c1 Mon Sep 17 00:00:00 2001 From: Yasir Date: Wed, 1 Apr 2026 10:28:20 +0300 Subject: [PATCH 119/189] fix notarization wasm core client parity --- .../notarization_wasm/src/wasm_notarization_client.rs | 9 +++++++++ .../src/wasm_notarization_client_read_only.rs | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/bindings/wasm/notarization_wasm/src/wasm_notarization_client.rs b/bindings/wasm/notarization_wasm/src/wasm_notarization_client.rs index 20efd4a2..7578b6e6 100644 --- a/bindings/wasm/notarization_wasm/src/wasm_notarization_client.rs +++ b/bindings/wasm/notarization_wasm/src/wasm_notarization_client.rs @@ -94,6 +94,15 @@ impl WasmNotarizationClient { .collect() } + /// Retrieves the [`TfComponents`] package ID for the current network, if available. + /// + /// # Returns + /// The package ID as a string, or `undefined` when no package applies. + #[wasm_bindgen(js_name = tfComponentsPackageId)] + pub fn tf_components_package_id(&self) -> Option { + None + } + /// Retrieves the IOTA client instance. /// /// # Returns diff --git a/bindings/wasm/notarization_wasm/src/wasm_notarization_client_read_only.rs b/bindings/wasm/notarization_wasm/src/wasm_notarization_client_read_only.rs index dd6f838a..9ddd7b25 100644 --- a/bindings/wasm/notarization_wasm/src/wasm_notarization_client_read_only.rs +++ b/bindings/wasm/notarization_wasm/src/wasm_notarization_client_read_only.rs @@ -86,6 +86,15 @@ impl WasmNotarizationClientReadOnly { .collect() } + /// Retrieves the [`TfComponents`] package ID for the current network, if available. + /// + /// # Returns + /// The package ID as a string, or `undefined` when no package applies. + #[wasm_bindgen(js_name = tfComponentsPackageId)] + pub fn tf_components_package_id(&self) -> Option { + None + } + /// Retrieves the underlying IOTA client used by this client. /// /// # Returns From 2f3595c448fb2b21da3a4aaf25af6c5388274bf8 Mon Sep 17 00:00:00 2001 From: Yasir Date: Wed, 1 Apr 2026 12:14:48 +0300 Subject: [PATCH 120/189] chore: extend CI/CD workflows to support audit trails --- .github/actions/publish/wasm/action.yml | 5 +- .github/workflows/build-and-test.yml | 80 ++++++++++++++++++++----- .github/workflows/clippy.yml | 6 ++ .github/workflows/format.yml | 9 ++- .github/workflows/shared-build-wasm.yml | 25 +++++--- .github/workflows/upload-docs.yml | 31 ++++++++-- .github/workflows/wasm-publish.yml | 54 +++++++++++++++-- .github/workflows/wasm-retag-npm.yml | 18 +++++- dprint.json | 3 +- 9 files changed, 196 insertions(+), 35 deletions(-) diff --git a/.github/actions/publish/wasm/action.yml b/.github/actions/publish/wasm/action.yml index 4fc39627..ba44b905 100644 --- a/.github/actions/publish/wasm/action.yml +++ b/.github/actions/publish/wasm/action.yml @@ -10,6 +10,9 @@ inputs: working-directory: description: "Directory to publish from" required: true + artifact-download-path: + description: "Directory to download artifacts to (defaults to working-directory)" + required: false dry-run: description: "'true' = only log potential result; 'false' = publish'" required: true @@ -27,7 +30,7 @@ runs: uses: actions/download-artifact@v4 with: name: ${{ inputs.input-artifact-name }} - path: bindings/wasm/notarization_wasm + path: ${{ inputs.artifact-download-path || inputs.working-directory }} - name: Publish WASM bindings to NPM shell: sh diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 830ff1b5..99863cbb 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -165,17 +165,27 @@ jobs: - name: test Notarization Move package if: matrix.os != 'windows-latest' - # publish the package and set the IOTA_NOTARIZATION_PKG_ID env variable - run: | - iota move test + run: iota move test working-directory: notarization-move + - name: test Audit Trail Move package + if: matrix.os != 'windows-latest' + run: iota move test + working-directory: audit-trail-move + - name: publish Notarization Move package if: matrix.os != 'windows-latest' - # publish the package and set the IOTA_NOTARIZATION_PKG_ID env variable run: echo "IOTA_NOTARIZATION_PKG_ID=$(./publish_package.sh)" >> "$GITHUB_ENV" working-directory: notarization-move/scripts/ + - name: publish Audit Trail Move package + if: matrix.os != 'windows-latest' + run: | + eval "$(./publish_package.sh)" + echo "IOTA_AUDIT_TRAIL_PKG_ID=$IOTA_AUDIT_TRAIL_PKG_ID" >> "$GITHUB_ENV" + echo "IOTA_TF_COMPONENTS_PKG_ID=$IOTA_TF_COMPONENTS_PKG_ID" >> "$GITHUB_ENV" + working-directory: audit-trail-move/scripts/ + - name: Run tests if: matrix.os != 'windows-latest' run: cargo test --workspace --release -- --test-threads=1 @@ -210,7 +220,7 @@ jobs: uses: "./.github/actions/rust/sccache/stop" with: os: ${{matrix.os}} - build-wasm: + build-wasm-notarization: needs: check-for-run-condition if: ${{ needs.check-for-run-condition.outputs.should-run == 'true' }} uses: "./.github/workflows/shared-build-wasm.yml" @@ -218,8 +228,18 @@ jobs: run-unit-tests: false output-artifact-name: notarization-wasm-bindings-build - test-wasm: - needs: build-wasm + build-wasm-audit-trail: + needs: check-for-run-condition + if: ${{ needs.check-for-run-condition.outputs.should-run == 'true' }} + uses: "./.github/workflows/shared-build-wasm.yml" + with: + run-unit-tests: false + output-artifact-name: audit-trail-wasm-bindings-build + wasm-package-dir: bindings/wasm/audit_trail_wasm + wasm-crate-name: audit_trail_wasm + + test-wasm-notarization: + needs: build-wasm-notarization if: ${{ needs.check-for-run-condition.outputs.should-run == 'true' }} runs-on: ubuntu-24.04 strategy: @@ -253,21 +273,53 @@ jobs: iota-version: ${{ env.IOTA_VERSION }} - name: publish Notarization Move package - if: matrix.os != 'windows-latest' - # publish the package and set the IOTA_NOTARIZATION_PKG_ID env variable 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-audit-trail: + needs: build-wasm-audit-trail + 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: 20.x + - name: Install JS dependencies run: npm ci - working-directory: bindings/wasm/notarization_wasm + working-directory: bindings/wasm/audit_trail_wasm + + - name: Download bindings/wasm/audit_trail_wasm artifacts + uses: actions/download-artifact@v4 + with: + name: audit-trail-wasm-bindings-build + path: bindings/wasm/audit_trail_wasm + + - name: Start iota sandbox + uses: "./.github/actions/iota/setup" + with: + iota-version: ${{ env.IOTA_VERSION }} + + - name: publish Audit Trail Move package + run: | + eval "$(./publish_package.sh)" + echo "IOTA_AUDIT_TRAIL_PKG_ID=$IOTA_AUDIT_TRAIL_PKG_ID" >> "$GITHUB_ENV" + echo "IOTA_TF_COMPONENTS_PKG_ID=$IOTA_TF_COMPONENTS_PKG_ID" >> "$GITHUB_ENV" + working-directory: audit-trail-move/scripts/ - name: Run Wasm examples - #run: npm run test:readme && npm run test:node run: npm run test:node - working-directory: bindings/wasm/notarization_wasm - test-wasm-browser: - needs: build-wasm + working-directory: bindings/wasm/audit_trail_wasm + test-wasm-browser-notarization: + needs: build-wasm-notarization if: ${{ needs.check-for-run-condition.outputs.should-run == 'true' }} runs-on: ubuntu-24.04 strategy: diff --git a/.github/workflows/clippy.yml b/.github/workflows/clippy.yml index b542cd19..16239453 100644 --- a/.github/workflows/clippy.yml +++ b/.github/workflows/clippy.yml @@ -49,3 +49,9 @@ jobs: if: ${{ false }} with: args: --manifest-path ./bindings/wasm/notarization_wasm/Cargo.toml --target wasm32-unknown-unknown --all-targets --all-features -- -D warnings + + - name: Wasm clippy check audit_trail_wasm + uses: actions-rs-plus/clippy-check@b09a9c37c9df7db8b1a5d52e8fe8e0b6e3d574c4 + if: ${{ false }} + with: + args: --manifest-path ./bindings/wasm/audit_trail_wasm/Cargo.toml --target wasm32-unknown-unknown --all-targets --all-features -- -D warnings diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index eeb124f3..13e1a5c9 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -49,6 +49,9 @@ jobs: - name: wasm fmt check notarization_wasm run: cargo +nightly fmt --manifest-path ./bindings/wasm/notarization_wasm/Cargo.toml --all -- --check + - name: wasm fmt check audit_trail_wasm + run: cargo +nightly fmt --manifest-path ./bindings/wasm/audit_trail_wasm/Cargo.toml --all -- --check + - name: fmt check with dprint run: dprint check @@ -61,6 +64,10 @@ jobs: - name: Install prettier-plugin-move run: npm i @mysten/prettier-plugin-move - - name: prettier-move check + - name: prettier-move check notarization-move working-directory: notarization-move run: npx prettier-move -c **/*.move + + - name: prettier-move check audit-trail-move + working-directory: audit-trail-move + run: npx prettier-move -c **/*.move diff --git a/.github/workflows/shared-build-wasm.yml b/.github/workflows/shared-build-wasm.yml index 9e4a432a..4f8c954b 100644 --- a/.github/workflows/shared-build-wasm.yml +++ b/.github/workflows/shared-build-wasm.yml @@ -19,6 +19,16 @@ on: description: "Name used for the output build artifact" required: true type: string + wasm-package-dir: + description: "Relative path to the wasm package directory (e.g. bindings/wasm/notarization_wasm)" + required: false + type: string + default: "bindings/wasm/notarization_wasm" + wasm-crate-name: + description: "Name of the wasm crate (e.g. notarization_wasm)" + required: false + type: string + default: "notarization_wasm" jobs: build-wasm: defaults: @@ -52,6 +62,7 @@ jobs: sccache-enabled: true sccache-path: ${{ matrix.sccache-path }} target-cache-path: bindings/wasm/target + target-cache-key-suffix: ${{ inputs.wasm-crate-name }} # Download a pre-compiled wasm-bindgen binary. - name: Install wasm-bindgen-cli @@ -71,16 +82,16 @@ jobs: - name: Install JS dependencies run: npm ci - working-directory: bindings/wasm/notarization_wasm + working-directory: ${{ inputs.wasm-package-dir }} - name: Build WASM bindings run: npm run build - working-directory: bindings/wasm/notarization_wasm + working-directory: ${{ inputs.wasm-package-dir }} - name: Run Node unit tests if: ${{ inputs.run-unit-tests }} run: npm run test:unit:node - working-directory: bindings/wasm/notarization_wasm + working-directory: ${{ inputs.wasm-package-dir }} - name: Stop sccache uses: "./.github/actions/rust/sccache/stop" @@ -92,9 +103,9 @@ jobs: with: name: ${{ inputs.output-artifact-name }} path: | - bindings/wasm/notarization_wasm/node - bindings/wasm/notarization_wasm/web - bindings/wasm/notarization_wasm/examples/dist - bindings/wasm/notarization_wasm/docs + ${{ inputs.wasm-package-dir }}/node + ${{ inputs.wasm-package-dir }}/web + ${{ inputs.wasm-package-dir }}/examples/dist + ${{ inputs.wasm-package-dir }}/docs if-no-files-found: error retention-days: 1 diff --git a/.github/workflows/upload-docs.yml b/.github/workflows/upload-docs.yml index a1579e29..d6d95673 100644 --- a/.github/workflows/upload-docs.yml +++ b/.github/workflows/upload-docs.yml @@ -16,20 +16,34 @@ permissions: actions: "write" jobs: - build-wasm: + build-wasm-notarization: uses: "./.github/workflows/shared-build-wasm.yml" with: run-unit-tests: false ref: ${{ inputs.ref }} output-artifact-name: notarization-docs + build-wasm-audit-trail: + uses: "./.github/workflows/shared-build-wasm.yml" + with: + run-unit-tests: false + ref: ${{ inputs.ref }} + output-artifact-name: audit-trail-docs + wasm-package-dir: bindings/wasm/audit_trail_wasm + wasm-crate-name: audit_trail_wasm + upload-docs: runs-on: ubuntu-latest - needs: build-wasm + needs: [build-wasm-notarization, build-wasm-audit-trail] steps: - uses: actions/download-artifact@v4 with: name: notarization-docs + path: notarization-docs + - uses: actions/download-artifact@v4 + with: + name: audit-trail-docs + path: audit-trail-docs - name: Get release version id: get_release_version run: | @@ -42,12 +56,21 @@ jobs: echo VERSION=$VERSION >> $GITHUB_OUTPUT - name: Compress generated docs run: | - tar czvf wasm.tar.gz notarization/docs/* + tar czvf wasm.tar.gz notarization-docs/docs/* + tar czvf audit-trail-wasm.tar.gz audit-trail-docs/docs/* - - name: Upload docs to AWS S3 + - name: Upload notarization docs to AWS S3 env: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID_IOTA_WIKI }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY_IOTA_WIKI }} AWS_DEFAULT_REGION: "eu-central-1" run: | aws s3 cp wasm.tar.gz s3://files.iota.org/iota-wiki/iota-notarization/${{ steps.get_release_version.outputs.VERSION }}/ --acl public-read + + - name: Upload audit trail docs to AWS S3 + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID_IOTA_WIKI }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY_IOTA_WIKI }} + AWS_DEFAULT_REGION: "eu-central-1" + run: | + aws s3 cp audit-trail-wasm.tar.gz s3://files.iota.org/iota-wiki/iota-audit-trail/${{ steps.get_release_version.outputs.VERSION }}/ --acl public-read diff --git a/.github/workflows/wasm-publish.yml b/.github/workflows/wasm-publish.yml index 5cbee4e1..5b86d731 100644 --- a/.github/workflows/wasm-publish.yml +++ b/.github/workflows/wasm-publish.yml @@ -27,6 +27,13 @@ on: retag-tag: description: "RETAG - Tag to set" required: false + package: + description: "Which package to publish/retag" + required: true + type: choice + options: + - notarization + - audit-trail permissions: id-token: write # Required for OIDC @@ -63,8 +70,8 @@ jobs: check_vars retag_version retag_tag fi - build-wasm: - if: ${{ github.event.inputs.operation == 'publish' }} + build-wasm-notarization: + if: ${{ github.event.inputs.operation == 'publish' && github.event.inputs.package == 'notarization' }} needs: [check-inputs] uses: "./.github/workflows/shared-build-wasm.yml" with: @@ -72,10 +79,21 @@ jobs: ref: ${{ github.event.inputs.publish-branch }} output-artifact-name: notarization-wasm-bindings-build - release-wasm: - if: ${{ github.event.inputs.operation == 'publish' }} + build-wasm-audit-trail: + if: ${{ github.event.inputs.operation == 'publish' && github.event.inputs.package == 'audit-trail' }} + needs: [check-inputs] + uses: "./.github/workflows/shared-build-wasm.yml" + with: + run-unit-tests: false + ref: ${{ github.event.inputs.publish-branch }} + output-artifact-name: audit-trail-wasm-bindings-build + wasm-package-dir: bindings/wasm/audit_trail_wasm + wasm-crate-name: audit_trail_wasm + + release-wasm-notarization: + if: ${{ github.event.inputs.operation == 'publish' && github.event.inputs.package == 'notarization' }} runs-on: ubuntu-latest - needs: [build-wasm] + needs: [build-wasm-notarization] steps: - name: Checkout uses: actions/checkout@v4 @@ -89,6 +107,23 @@ jobs: working-directory: ./bindings/wasm/notarization_wasm tag: ${{ github.event.inputs.publish-tag }} + release-wasm-audit-trail: + if: ${{ github.event.inputs.operation == 'publish' && github.event.inputs.package == 'audit-trail' }} + runs-on: ubuntu-latest + needs: [build-wasm-audit-trail] + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.event.inputs.publish-branch }} + - name: Release to npm + uses: "./.github/actions/publish/wasm" + with: + dry-run: ${{ github.event.inputs.publish-dry-run }} + input-artifact-name: audit-trail-wasm-bindings-build + working-directory: ./bindings/wasm/audit_trail_wasm + tag: ${{ github.event.inputs.publish-tag }} + retag-wasm: if: ${{ github.event.inputs.operation == 'retag' }} needs: [check-inputs] @@ -100,7 +135,14 @@ jobs: node-version: "lts/*" registry-url: "https://registry.npmjs.org" - - name: Run dist-tag + - name: Run dist-tag notarization + if: ${{ github.event.inputs.package == 'notarization' }} shell: bash run: | npm dist-tag add @iota/notarization@${{ github.event.inputs.retag-version }} ${{ github.event.inputs.retag-tag }} + + - name: Run dist-tag audit-trail + if: ${{ github.event.inputs.package == 'audit-trail' }} + shell: bash + run: | + npm dist-tag add @iota/audit-trail@${{ github.event.inputs.retag-version }} ${{ github.event.inputs.retag-tag }} diff --git a/.github/workflows/wasm-retag-npm.yml b/.github/workflows/wasm-retag-npm.yml index 3830bec7..e51b3f0d 100644 --- a/.github/workflows/wasm-retag-npm.yml +++ b/.github/workflows/wasm-retag-npm.yml @@ -9,6 +9,13 @@ on: version: description: "version to set" required: true + package: + description: "Which package to retag" + required: true + type: choice + options: + - notarization + - audit-trail jobs: release-wasm: @@ -21,9 +28,18 @@ jobs: node-version: "20.x" registry-url: "https://registry.npmjs.org" - - name: Run dist-tag + - name: Run dist-tag notarization + if: ${{ github.event.inputs.package == 'notarization' }} shell: sh env: NODE_AUTH_TOKEN: ${{ secrets.NPM_NOTARIZATION_TOKEN }} run: | npm dist-tag add @iota/notarization@${{ github.event.inputs.version }} ${{ github.event.inputs.tag }} + + - name: Run dist-tag audit-trail + if: ${{ github.event.inputs.package == 'audit-trail' }} + shell: sh + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_NOTARIZATION_TOKEN }} + run: | + npm dist-tag add @iota/audit-trail@${{ github.event.inputs.version }} ${{ github.event.inputs.tag }} diff --git a/dprint.json b/dprint.json index e5444f58..5eee3a23 100644 --- a/dprint.json +++ b/dprint.json @@ -14,7 +14,8 @@ "excludes": [ "**/*-lock.json", "**/{node_modules, target}", - "bindings/wasm/notarization_wasm/{node,web}/**/*.{js,ts}" + "bindings/wasm/notarization_wasm/{node,web}/**/*.{js,ts}", + "bindings/wasm/audit_trail_wasm/{node,web}/**/*.{js,ts}" ], "plugins": [ "https://plugins.dprint.dev/markdown-0.18.0.wasm", From d75ce2c4d789b74eaad0b4a4e4290c20d3cf5ce9 Mon Sep 17 00:00:00 2001 From: Yasir Date: Wed, 1 Apr 2026 12:52:45 +0300 Subject: [PATCH 121/189] fix: replace iota start with iota-localnet start --- .github/actions/iota/setup/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/iota/setup/action.yml b/.github/actions/iota/setup/action.yml index 992a8a92..c049fc09 100644 --- a/.github/actions/iota/setup/action.yml +++ b/.github/actions/iota/setup/action.yml @@ -97,7 +97,7 @@ runs: echo "Starting server with log file: $LOGFILE" # Start the network - iota start --with-faucet ${{ inputs.logfile && format('> {0} 2>&1', inputs.logfile) || '' }} & + iota-localnet start --with-faucet ${{ inputs.logfile && format('> {0} 2>&1', inputs.logfile) || '' }} & - name: Setup TOML CLI utils shell: bash run: | From 466ebdc206e3e81f40d676811cc55c0f030516a5 Mon Sep 17 00:00:00 2001 From: chrisgitiota Date: Tue, 31 Mar 2026 16:20:51 +0200 Subject: [PATCH 122/189] Add .history to git ignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 1fc1232d..fca3c3a3 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ *.code-workspace .idea +.history .DS_Store /notarization-move/build/* /bindings/wasm/notarization_wasm/docs/* From fc98670bd62bff17256c205a46b82c3bfa7d217d Mon Sep 17 00:00:00 2001 From: Yasir Date: Wed, 1 Apr 2026 16:58:00 +0300 Subject: [PATCH 123/189] docs: improve notarization and audit trail docs --- README.md | 54 ++++++---- audit-trail-move/README.md | 90 ++++++++++++++++ audit-trail-rs/README.md | 100 +++++++++++++++++- audit-trail-rs/src/client/full_client.rs | 51 +++++---- audit-trail-rs/src/client/mod.rs | 10 +- audit-trail-rs/src/client/read_only.rs | 58 ++++++++-- audit-trail-rs/src/core/access/mod.rs | 7 +- .../src/core/access/transactions.rs | 18 ++++ audit-trail-rs/src/core/builder.rs | 6 ++ audit-trail-rs/src/core/create/mod.rs | 2 + .../src/core/create/transactions.rs | 8 ++ audit-trail-rs/src/core/locking/mod.rs | 12 +++ .../src/core/locking/transactions.rs | 8 ++ audit-trail-rs/src/core/mod.rs | 21 +++- audit-trail-rs/src/core/records/mod.rs | 21 ++++ .../src/core/records/transactions.rs | 17 +++ audit-trail-rs/src/core/tags/mod.rs | 3 + audit-trail-rs/src/core/tags/transactions.rs | 4 + audit-trail-rs/src/core/trail.rs | 21 ++++ audit-trail-rs/src/core/trail/transactions.rs | 6 ++ audit-trail-rs/src/core/types/audit_trail.rs | 21 ++++ audit-trail-rs/src/core/types/event.rs | 55 ++++++++++ audit-trail-rs/src/core/types/locking.rs | 13 +++ audit-trail-rs/src/core/types/mod.rs | 12 ++- audit-trail-rs/src/core/types/permission.rs | 31 +++++- audit-trail-rs/src/core/types/record.rs | 61 +++++++++++ audit-trail-rs/src/core/types/role_map.rs | 29 +++++ audit-trail-rs/src/error.rs | 20 ++-- audit-trail-rs/src/lib.rs | 8 ++ bindings/wasm/audit_trail_wasm/README.md | 76 ++++++++++--- bindings/wasm/audit_trail_wasm/src/builder.rs | 9 ++ bindings/wasm/audit_trail_wasm/src/client.rs | 31 ++++++ .../audit_trail_wasm/src/client_read_only.rs | 31 ++++++ bindings/wasm/audit_trail_wasm/src/lib.rs | 4 + bindings/wasm/audit_trail_wasm/src/trail.rs | 37 +++++++ .../src/trail_handle/access.rs | 13 +++ .../src/trail_handle/locking.rs | 6 ++ .../audit_trail_wasm/src/trail_handle/mod.rs | 16 +++ .../src/trail_handle/records.rs | 10 ++ .../audit_trail_wasm/src/trail_handle/tags.rs | 4 + bindings/wasm/audit_trail_wasm/src/types.rs | 67 ++++++++++++ notarization-move/README.md | 86 +++++++++++++++ 42 files changed, 1076 insertions(+), 81 deletions(-) create mode 100644 audit-trail-move/README.md create mode 100644 notarization-move/README.md diff --git a/README.md b/README.md index 0efb11a3..4244a4df 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@

Introduction ◈ + PackagesDocumentation & ResourcesBindingsContributing @@ -16,41 +17,58 @@ --- -# IOTA Notarization +# IOTA Notarization And Audit Trail ## Introduction -IOTA Notarization enables the creation of immutable, on-chain records for any arbitrary data. This is achieved by storing the data, or a hash of it, inside a dedicated Move object on the IOTA ledger. This process provides a verifiable, timestamped proof of the data's existence and integrity at a specific point in time. +This repository contains two complementary IOTA ledger toolkits: -IOTA Notarization is composed of two primary components: +- **IOTA Notarization** + Creates verifiable on-chain proof objects for arbitrary data, including dynamic and locked notarization flows. +- **IOTA Audit Trail** + Creates shared on-chain audit trails with sequential records, role-based access control, locking, and tagging. -- **Notarization Move Package**: The on-chain smart contracts that define the behavior and structure of notarization objects. -- **Notarization Library (Rust/Wasm)**: A client-side library that provides developers with convenient functions to create, manage, and verify `Notarization` objects on the network. +Each toolkit is split into: -## Documentation and Resources +- a Move package that defines the on-chain object model and behavior +- a Rust SDK that provides typed client access and transaction builders +- wasm bindings for JavaScript and TypeScript integrations -- [Notarization Documentation Pages](https://docs.iota.org/developer/iota-notarization): Supplementing documentation with context around notarization and simple examples on library usage. -- API References: - - [Rust API Reference](https://iotaledger.github.io/notarization/notarization/index.html): Package documentation (cargo docs). +## Packages - +| Toolkit | Move Package | Rust SDK | Wasm SDK | +| ------- | ------------ | -------- | -------- | +| Notarization | [`notarization-move`](./notarization-move) | [`notarization-rs`](./notarization-rs) | [`bindings/wasm/notarization_wasm`](./bindings/wasm/notarization_wasm) | +| Audit Trail | [`audit-trail-move`](./audit-trail-move) | [`audit-trail-rs`](./audit-trail-rs) | [`bindings/wasm/audit_trail_wasm`](./bindings/wasm/audit_trail_wasm) | -- Examples: - - [Rust Examples](https://github.com/iotaledger/notarization/tree/main/examples/README.md): Practical code snippets to get you started with the library in Rust. - - [Wasm Examples](https://github.com/iotaledger/notarization/tree/main/bindings/wasm/notarization_wasm/examples/README.md): Practical code snippets to get you started with the library in TypeScript/JavaScript. +## Documentation And Resources + +- IOTA Notarization: + - [Notarization Rust SDK README](https://github.com/iotaledger/notarization/tree/main/notarization-rs/README.md) + - [Notarization Wasm README](https://github.com/iotaledger/notarization/tree/main/bindings/wasm/notarization_wasm/README.md) + - [Notarization Examples](https://github.com/iotaledger/notarization/tree/main/bindings/wasm/notarization_wasm/examples/README.md) + - [IOTA Notarization Docs Portal](https://docs.iota.org/developer/iota-notarization) +- IOTA Audit Trail: + - [Audit Trail Rust SDK README](https://github.com/iotaledger/notarization/tree/main/audit-trail-rs/README.md) + - [Audit Trail Move Package README](https://github.com/iotaledger/notarization/tree/main/audit-trail-move/README.md) + - [Audit Trail Wasm README](https://github.com/iotaledger/notarization/tree/main/bindings/wasm/audit_trail_wasm/README.md) + - [Audit Trail Wasm Examples](https://github.com/iotaledger/notarization/tree/main/bindings/wasm/audit_trail_wasm/examples/README.md) +- Shared: + - [Repository Examples](https://github.com/iotaledger/notarization/tree/main/examples/README.md) ## Bindings -[Foreign Function Interface (FFI)](https://en.wikipedia.org/wiki/Foreign_function_interface) Bindings of this [Rust](https://www.rust-lang.org/) library to other programming languages: +[Foreign Function Interface (FFI)](https://en.wikipedia.org/wiki/Foreign_function_interface) bindings in this repository: -- [Web Assembly](https://github.com/iotaledger/notarization/tree/main/bindings/wasm/notarization_wasm) (JavaScript/TypeScript) +- [Web Assembly](https://github.com/iotaledger/notarization/tree/main/bindings/wasm/notarization_wasm) for IOTA Notarization +- [Web Assembly](https://github.com/iotaledger/notarization/tree/main/bindings/wasm/audit_trail_wasm) for IOTA Audit Trail ## Contributing -We would love to have you help us with the development of IOTA Notarization. Each and every contribution is greatly valued! +We would love to have you help us with the development of IOTA Notarization and Audit Trail. Each and every contribution is greatly valued. Please review the [contribution](https://docs.iota.org/developer/iota-notarization/contribute) sections in the [IOTA Docs Portal](https://docs.iota.org/developer/iota-notarization/). -To contribute directly to the repository, simply fork the project, push your changes to your fork and create a pull request to get them included! +To contribute directly to the repository, simply fork the project, push your changes to your fork and create a pull request to get them included. -The best place to get involved in discussions about this library or to look for support at is the `#notarization` channel on the [IOTA Discord](https://discord.gg/iota-builders). You can also ask questions on our [Stack Exchange](https://iota.stackexchange.com/). +The best place to get involved in discussions about these libraries or to look for support at is the `#notarization` channel on the [IOTA Discord](https://discord.gg/iota-builders). You can also ask questions on our [Stack Exchange](https://iota.stackexchange.com/). diff --git a/audit-trail-move/README.md b/audit-trail-move/README.md new file mode 100644 index 00000000..41ab23fd --- /dev/null +++ b/audit-trail-move/README.md @@ -0,0 +1,90 @@ +![banner](https://github.com/iotaledger/notarization/raw/HEAD/.github/banner_notarization.png) + +

+ StackExchange + Discord + Apache 2.0 license +

+ +

+ Introduction ◈ + Modules ◈ + Development & Testing ◈ + Related Libraries ◈ + Contributing +

+ +--- + +# IOTA Audit Trail Move Package + +## Introduction + +`audit-trail-move` is the on-chain Move package behind IOTA Audit Trail. + +It defines the shared `AuditTrail` object and the supporting types needed for: + +- sequential record storage +- role-based access control through capabilities +- trail-wide locking for writes and deletions +- record tags and role tag restrictions +- immutable and updatable trail metadata +- emitted events for trail and record lifecycle changes + +The package depends on `TfComponents` for reusable capability, role-map, and timelock primitives. + +## Modules + +- `audit_trail::main` + Core shared object, events, trail lifecycle, record mutation, metadata updates, roles, and capabilities. +- `audit_trail::record` + Record payloads, initial records, and correction metadata. +- `audit_trail::locking` + Locking configuration and lock evaluation helpers. +- `audit_trail::permission` + Permission constructors and admin permission presets. +- `audit_trail::record_tags` + Tag registry and role tag helpers. + +## Development And Testing + +Build the Move package: + +```bash +cd audit-trail-move +iota move build +``` + +Run the Move test suite: + +```bash +cd audit-trail-move +iota move test +``` + +Publish locally: + +```bash +cd audit-trail-move +./scripts/publish_package.sh +``` + +The publish script prints `IOTA_AUDIT_TRAIL_PKG_ID` and, on `localnet`, also exports `IOTA_TF_COMPONENTS_PKG_ID`. + +The package history files [`Move.lock`](./Move.lock) and [`Move.history.json`](./Move.history.json) are used by the Rust SDK to resolve and track deployed package versions. + +## Related Libraries + +- [Rust SDK](https://github.com/iotaledger/notarization/tree/main/audit-trail-rs/README.md) +- [Wasm SDK](https://github.com/iotaledger/notarization/tree/main/bindings/wasm/audit_trail_wasm/README.md) +- [Repository Root](https://github.com/iotaledger/notarization/tree/main/README.md) + +## Contributing + +We would love to have you help us with the development of IOTA Audit Trail. Each and every contribution is greatly valued. + +Please review the [contribution](https://docs.iota.org/developer/iota-notarization/contribute) sections in the [IOTA Docs Portal](https://docs.iota.org/developer/iota-notarization/). + +To contribute directly to the repository, simply fork the project, push your changes to your fork and create a pull request to get them included. + +The best place to get involved in discussions about this package or to look for support at is the `#notarization` channel on the [IOTA Discord](https://discord.gg/iota-builders). You can also ask questions on our [Stack Exchange](https://iota.stackexchange.com/). diff --git a/audit-trail-rs/README.md b/audit-trail-rs/README.md index 97a329c2..45b98fa9 100644 --- a/audit-trail-rs/README.md +++ b/audit-trail-rs/README.md @@ -1 +1,99 @@ -# IOTA Audit Trail (WIP) +![banner](https://github.com/iotaledger/notarization/raw/HEAD/.github/banner_notarization.png) + +

+ StackExchange + Discord + Apache 2.0 license +

+ +

+ Introduction ◈ + Documentation & Resources ◈ + Feature Overview ◈ + Bindings ◈ + Contributing +

+ +--- + +# IOTA Audit Trail Rust SDK + +## Introduction + +`audit_trail` is the Rust SDK for reading and writing audit trails on the IOTA ledger. + +An audit trail is a shared on-chain object that stores a sequential series of records together with: + +- role-based access control backed by capabilities +- trail-level locking rules for writes and deletions +- tag registries for record categorization +- immutable creation metadata and optional updatable metadata + +The crate provides: + +- read-only and signing client wrappers for the on-chain audit-trail package +- typed trail handles for records, locking, access control, and tags +- serializable Rust representations of on-chain objects and emitted events +- transaction builders that integrate with the shared `product_common` transaction flow + +## Documentation And Resources + +- [Audit Trail Move Package](https://github.com/iotaledger/notarization/tree/main/audit-trail-move): On-chain contract package that defines the shared object model, permissions, locking, and events. +- [Wasm SDK](https://github.com/iotaledger/notarization/tree/main/bindings/wasm/audit_trail_wasm): JavaScript and TypeScript bindings for browser and Node.js integrations. +- [Wasm Examples](https://github.com/iotaledger/notarization/tree/main/bindings/wasm/audit_trail_wasm/examples/README.md): Runnable audit-trail examples for JS and TS consumers. +- [Repository Examples](https://github.com/iotaledger/notarization/tree/main/examples/README.md): End-to-end examples across the broader repository. + +This README is also used as the crate-level rustdoc entry point, while the source files provide detailed API documentation for all public types and methods. + +## Feature Overview + +The public API is organized around a small set of entry points: + +- [`AuditTrailClientReadOnly`] for package resolution, trail-scoped reads, and inspected transactions +- [`AuditTrailClient`] for signed write flows +- [`AuditTrailHandle`] for operations scoped to one trail object +- [`AuditTrailBuilder`] for configuring trail creation +- [`core::types`] for domain types such as [`Data`], [`Record`], [`LockingConfig`], and [`PermissionSet`] + +Typical flow: + +1. Construct an [`AuditTrailClientReadOnly`] or [`AuditTrailClient`]. +2. Resolve a trail with [`AuditTrailClientReadOnly::trail`] or [`AuditTrailClient::trail`]. +3. Read state with [`AuditTrailHandle::get`] or move into one of the trail subsystems: + - [`AuditTrailHandle::records`] + - [`AuditTrailHandle::locking`] + - [`AuditTrailHandle::access`] + - [`AuditTrailHandle::tags`] +4. For writes, build a typed transaction from the client, trail handle, or subsystem handle and execute it through the surrounding transaction infrastructure. + +The crate deliberately separates transaction construction from submission so applications can keep signing, sponsorship, gas selection, and batching policy outside the SDK. + +Pure value types expose executable doctests where the behavior is self-contained and stable: + +```rust +use audit_trail::core::types::{Data, InitialRecord}; + +let record = InitialRecord::new(Data::text("hello"), Some("first write".to_string()), None); + +assert_eq!(record.data, Data::text("hello")); +assert_eq!(record.metadata.as_deref(), Some("first write")); +assert!(record.tag.is_none()); +``` + +If you are integrating against a custom deployment, use [`PackageOverrides`] during client construction so the crate does not rely on the built-in package registry for that environment. + +## Bindings + +[Foreign Function Interface (FFI)](https://en.wikipedia.org/wiki/Foreign_function_interface) bindings of this Rust SDK to other programming languages: + +- [Web Assembly](https://github.com/iotaledger/notarization/tree/main/bindings/wasm/audit_trail_wasm) (JavaScript/TypeScript) + +## Contributing + +We would love to have you help us with the development of IOTA Audit Trail. Each and every contribution is greatly valued. + +Please review the [contribution](https://docs.iota.org/developer/iota-notarization/contribute) sections in the [IOTA Docs Portal](https://docs.iota.org/developer/iota-notarization/). + +To contribute directly to the repository, simply fork the project, push your changes to your fork and create a pull request to get them included. + +The best place to get involved in discussions about this library or to look for support at is the `#notarization` channel on the [IOTA Discord](https://discord.gg/iota-builders). You can also ask questions on our [Stack Exchange](https://iota.stackexchange.com/). \ No newline at end of file diff --git a/audit-trail-rs/src/client/full_client.rs b/audit-trail-rs/src/client/full_client.rs index 2d2a61b3..49b94e97 100644 --- a/audit-trail-rs/src/client/full_client.rs +++ b/audit-trail-rs/src/client/full_client.rs @@ -1,9 +1,10 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -//! A full client wrapper for audit trail interactions. +//! Signing client support for audit-trail interactions. //! -//! This client includes signing capabilities for executing transactions. +//! [`AuditTrailClient`] combines an [`AuditTrailClientReadOnly`] with a signer so the crate can +//! build typed write transactions against the connected network. use std::ops::Deref; @@ -32,10 +33,9 @@ use crate::iota_interaction_adapter::IotaClientAdapter; #[non_exhaustive] pub struct NoSigner; -/// The error that results from a failed attempt at creating an [IdentityClient] -/// from a given [IotaClient]. +/// Error returned when constructing an [`AuditTrailClient`] from an IOTA client fails. #[derive(Debug, thiserror::Error)] -#[error("failed to create an 'IdentityClient' from the given 'IotaClient'")] +#[error("failed to create an 'AuditTrailClient' from the given 'IotaClient'")] #[non_exhaustive] pub struct FromIotaClientError { /// Type of failure for this error. @@ -43,12 +43,12 @@ pub struct FromIotaClientError { pub kind: FromIotaClientErrorKind, } -/// Types of failure for [FromIotaClientError]. +/// Categories of failure for [`FromIotaClientError`]. #[derive(Debug, thiserror::Error)] #[non_exhaustive] pub enum FromIotaClientErrorKind { /// A package ID is required, but was not supplied. - #[error("an IOTA Identity package ID must be supplied when connecting to an unofficial IOTA network")] + #[error("an audit-trail package ID must be supplied when connecting to an unofficial IOTA network")] MissingPackageId, /// Network ID resolution through an RPC call failed. #[error("failed to resolve the network the given client is connected to")] @@ -71,15 +71,16 @@ impl Deref for AuditTrailClient { } impl AuditTrailClient { - /// Creates a new [AuditTrailClient], with **no** signing capabilities, from the given [IotaClient]. + /// Creates a new client with no signing capabilities from an IOTA client. /// /// # Warning - /// Passing `package_overrides` is **only** required when connecting to a custom IOTA network - /// or when testing against explicitly deployed package pairs. /// - /// Relying on a custom Audit Trail package when connected to an official IOTA network is **highly - /// discouraged** and is sure to result in compatibility issues when interacting with other official - /// IOTA Trust Framework's products. + /// Passing `package_overrides` is only needed when connecting to a custom IOTA network or + /// when testing against explicitly deployed package pairs. + /// + /// Relying on a custom audit-trail package while connected to an official IOTA network is + /// strongly discouraged and can lead to compatibility problems with other official IOTA Trust + /// Framework products. /// /// # Examples /// ```rust,ignore @@ -122,7 +123,11 @@ impl AuditTrailClient { } impl AuditTrailClient { - /// Creates a new client with signing capabilities from an existing read-only client. + /// Creates a signing client from an existing read-only client and signer. + /// + /// # Errors + /// + /// Returns an error if the signer public key cannot be loaded. pub async fn new(client: AuditTrailClientReadOnly, signer: S) -> Result where S: Signer, @@ -139,7 +144,11 @@ impl AuditTrailClient { }) } - /// Sets a new signer for this client. + /// Replaces the signer used by this client. + /// + /// # Errors + /// + /// Returns an error if the replacement signer public key cannot be loaded. pub async fn with_signer(self, signer: NewS) -> Result, secret_storage::Error> where NewS: Signer, @@ -152,10 +161,12 @@ impl AuditTrailClient { signer, }) } + /// Returns the underlying read-only client view. pub fn read_only(&self) -> &AuditTrailClientReadOnly { &self.read_client } + /// Returns a typed handle bound to a specific trail object ID. pub fn trail<'a>(&'a self, trail_id: ObjectID) -> AuditTrailHandle<'a, Self> { AuditTrailHandle::new(self, trail_id) } @@ -165,17 +176,16 @@ impl AuditTrailClient { self.read_client.tf_components_package_id() } - /// Creates a builder for an audit trail. + /// Creates a builder for a new audit trail. + /// + /// When the client has a signer, the builder is pre-populated with that signer's address as + /// the initial admin. pub fn create_trail(&self) -> AuditTrailBuilder { AuditTrailBuilder { admin: self.public_key.as_ref().map(IotaAddress::from), ..AuditTrailBuilder::default() } } - - pub async fn delete_trail(&self, _trail_id: ObjectID) -> Result<(), Error> { - Err(Error::NotImplemented("AuditTrailClient::delete_trail")) - } } impl AuditTrailClient @@ -239,6 +249,7 @@ impl AuditTrailReadOnly for AuditTrailClient where S: Signer + OptionalSync, { + /// Delegates read-only execution to the wrapped [`AuditTrailClientReadOnly`]. async fn execute_read_only_transaction( &self, tx: ProgrammableTransaction, diff --git a/audit-trail-rs/src/client/mod.rs b/audit-trail-rs/src/client/mod.rs index 79c06024..feef288f 100644 --- a/audit-trail-rs/src/client/mod.rs +++ b/audit-trail-rs/src/client/mod.rs @@ -1,7 +1,11 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -//! Client implementations for interacting with audit trails on the IOTA blockchain. +//! Client implementations for interacting with audit trails on the IOTA ledger. +//! +//! [`AuditTrailClientReadOnly`] is the entry point for read-only inspection and typed trail handles. +//! [`AuditTrailClient`] wraps a read-only client together with a signer so it can build write +//! transactions through the shared transaction infrastructure. use iota_interaction::IotaClientTrait; use product_common::network_name::NetworkName; @@ -9,13 +13,15 @@ use product_common::network_name::NetworkName; use crate::error::Error; use crate::iota_interaction_adapter::IotaClientAdapter; +/// A signing client that can create audit-trail transaction builders. pub mod full_client; +/// A read-only client that resolves package IDs and executes inspected calls. pub mod read_only; pub use full_client::*; pub use read_only::*; -/// Returns the network-id also known as chain-identifier provided by the specified iota_client +/// Resolves the network name reported by the given IOTA client. async fn network_id(iota_client: &IotaClientAdapter) -> Result { let network_id = iota_client .read_api() diff --git a/audit-trail-rs/src/client/read_only.rs b/audit-trail-rs/src/client/read_only.rs index 46c8ab0f..cd866eed 100644 --- a/audit-trail-rs/src/client/read_only.rs +++ b/audit-trail-rs/src/client/read_only.rs @@ -1,7 +1,11 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -//! A read-only client for interacting with IOTA Audit Trail module objects. +//! Read-only client support for audit-trail interactions. +//! +//! [`AuditTrailClientReadOnly`] resolves the deployed package IDs for the connected network, exposes +//! typed trail handles, and provides the internal read-only execution primitive used by the handle +//! APIs. use std::ops::Deref; @@ -22,14 +26,25 @@ use crate::error::Error; use crate::iota_interaction_adapter::IotaClientAdapter; use crate::package; -/// Optional package ID overrides used when constructing an audit trail client. +/// Explicit package-ID overrides used when constructing an audit-trail client. +/// +/// Use this when talking to custom deployments, local test networks, or any environment where the +/// package registry does not yet know the relevant package IDs. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct PackageOverrides { + /// Override for the audit-trail package itself. pub audit_trail_package_id: Option, + /// Override for the `tf_components` package used by time locks and capabilities. pub tf_components_package_id: Option, } -/// A read-only client for interacting with audit trail module objects on a specific network. +/// A read-only client for interacting with audit-trail objects on a specific network. +/// +/// This is the main entry point for applications that only need package resolution and typed read +/// helpers. Once constructed, use [`Self::trail`] to create lightweight handles scoped to a single +/// trail object. +/// +/// For write flows, wrap this client in [`crate::AuditTrailClient`]. #[derive(Clone)] pub struct AuditTrailClientReadOnly { /// The underlying IOTA client adapter used for communication. @@ -63,6 +78,8 @@ impl AuditTrailClientReadOnly { } /// Returns the package ID used by this client. + /// + /// This is the deployed audit-trail Move package ID, not a trail object ID. pub fn package_id(&self) -> ObjectID { self.audit_trail_pkg_id } @@ -77,14 +94,24 @@ impl AuditTrailClientReadOnly { &self.iota_client } - /// Returns a typed handle bound to a trail id. + /// Returns a typed handle bound to a specific trail object ID. + /// + /// Creating the handle is cheap. Reads only happen when you call methods on the returned + /// [`AuditTrailHandle`], such as [`AuditTrailHandle::get`]. pub fn trail<'a>(&'a self, trail_id: ObjectID) -> AuditTrailHandle<'a, Self> { AuditTrailHandle::new(self, trail_id) } - /// Attempts to create a new [`AuditTrailClientReadOnly`] from a given IOTA client. + /// Creates a new read-only client from an IOTA client. + /// + /// The package IDs are resolved from the internal registry using the connected network name. + /// This is the recommended constructor when connecting to official deployments whose package + /// history is already tracked by the crate. + /// + /// # Errors /// - /// This resolves the package ID from the internal registry based on the network. + /// Returns an error if the network cannot be resolved or if the package IDs for that network + /// cannot be determined. pub async fn new( #[cfg(target_arch = "wasm32")] iota_client: WasmIotaClient, #[cfg(not(target_arch = "wasm32"))] iota_client: IotaClient, @@ -111,11 +138,18 @@ impl AuditTrailClientReadOnly { }) } - /// Creates a new [`AuditTrailClientReadOnly`] with explicit package overrides. + /// Creates a new read-only client with explicit package-ID overrides. /// - /// This function allows overriding the package ID lookup from the registry, - /// which is useful for local testing or custom deployments where the package - /// IDs are known ahead of time. + /// This bypasses the default package-registry lookup for any IDs provided in + /// [`PackageOverrides`]. + /// + /// Prefer this constructor when talking to custom deployments, local networks, or preview + /// environments whose package IDs are not yet part of the built-in registry. + /// + /// # Errors + /// + /// Returns an error if the network cannot be resolved or if the resulting package-ID + /// configuration is invalid. pub async fn new_with_package_overrides( #[cfg(target_arch = "wasm32")] iota_client: WasmIotaClient, #[cfg(not(target_arch = "wasm32"))] iota_client: IotaClient, @@ -150,6 +184,10 @@ impl CoreClientReadOnly for AuditTrailClientReadOnly { #[cfg_attr(not(feature = "send-sync"), async_trait::async_trait(?Send))] #[cfg_attr(feature = "send-sync", async_trait::async_trait)] impl AuditTrailReadOnly for AuditTrailClientReadOnly { + /// Executes a programmable transaction through `dev_inspect` and decodes the first return + /// value as `T`. + /// + /// This is primarily used by the typed read-only handle APIs. async fn execute_read_only_transaction( &self, tx: ProgrammableTransaction, diff --git a/audit-trail-rs/src/core/access/mod.rs b/audit-trail-rs/src/core/access/mod.rs index 25a155b3..23feebda 100644 --- a/audit-trail-rs/src/core/access/mod.rs +++ b/audit-trail-rs/src/core/access/mod.rs @@ -1,6 +1,8 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +//! Role and capability management APIs for audit trails. + use iota_interaction::types::base_types::ObjectID; use iota_interaction::{IotaKeySignature, OptionalSync}; use product_common::core_client::CoreClient; @@ -18,6 +20,7 @@ pub use transactions::{ IssueCapability, RevokeCapability, RevokeInitialAdminCapability, UpdateRole, }; +/// Access-control API scoped to a specific trail. #[derive(Debug, Clone)] pub struct TrailAccess<'a, C> { pub(crate) client: &'a C, @@ -29,7 +32,7 @@ impl<'a, C> TrailAccess<'a, C> { Self { client, trail_id } } - /// Returns a handle bound to a specific role name. + /// Returns a role-scoped handle for the given role name. pub fn for_role(&self, name: impl Into) -> RoleHandle<'a, C> { RoleHandle::new(self.client, self.trail_id, name.into()) } @@ -111,6 +114,7 @@ impl<'a, C> TrailAccess<'a, C> { } } +/// Role-scoped access-control API. #[derive(Debug, Clone)] pub struct RoleHandle<'a, C> { pub(crate) client: &'a C, @@ -123,6 +127,7 @@ impl<'a, C> RoleHandle<'a, C> { Self { client, trail_id, name } } + /// Returns the role name represented by this handle. pub fn name(&self) -> &str { &self.name } diff --git a/audit-trail-rs/src/core/access/transactions.rs b/audit-trail-rs/src/core/access/transactions.rs index b26d605d..7534614a 100644 --- a/audit-trail-rs/src/core/access/transactions.rs +++ b/audit-trail-rs/src/core/access/transactions.rs @@ -19,6 +19,7 @@ use crate::error::Error; // ===== CreateRole ===== +/// Transaction that creates a role on a trail. #[derive(Debug, Clone)] pub struct CreateRole { trail_id: ObjectID, @@ -30,6 +31,7 @@ pub struct CreateRole { } impl CreateRole { + /// Creates a `CreateRole` transaction builder payload. pub fn new( trail_id: ObjectID, owner: IotaAddress, @@ -102,6 +104,7 @@ impl Transaction for CreateRole { } } +/// Transaction that updates an existing role. #[derive(Debug, Clone)] pub struct UpdateRole { trail_id: ObjectID, @@ -113,6 +116,7 @@ pub struct UpdateRole { } impl UpdateRole { + /// Creates an `UpdateRole` transaction builder payload. pub fn new( trail_id: ObjectID, owner: IotaAddress, @@ -185,6 +189,7 @@ impl Transaction for UpdateRole { } } +/// Transaction that deletes a role. #[derive(Debug, Clone)] pub struct DeleteRole { trail_id: ObjectID, @@ -194,6 +199,7 @@ pub struct DeleteRole { } impl DeleteRole { + /// Creates a `DeleteRole` transaction builder payload. pub fn new(trail_id: ObjectID, owner: IotaAddress, name: String) -> Self { Self { trail_id, @@ -250,6 +256,7 @@ impl Transaction for DeleteRole { } } +/// Transaction that issues a capability for a role. #[derive(Debug, Clone)] pub struct IssueCapability { trail_id: ObjectID, @@ -260,6 +267,7 @@ pub struct IssueCapability { } impl IssueCapability { + /// Creates an `IssueCapability` transaction builder payload. pub fn new(trail_id: ObjectID, owner: IotaAddress, role: String, options: CapabilityIssueOptions) -> Self { Self { trail_id, @@ -324,6 +332,7 @@ impl Transaction for IssueCapability { } } +/// Transaction that revokes a capability. #[derive(Debug, Clone)] pub struct RevokeCapability { trail_id: ObjectID, @@ -334,6 +343,7 @@ pub struct RevokeCapability { } impl RevokeCapability { + /// Creates a `RevokeCapability` transaction builder payload. pub fn new( trail_id: ObjectID, owner: IotaAddress, @@ -403,6 +413,7 @@ impl Transaction for RevokeCapability { } } +/// Transaction that destroys a capability object. #[derive(Debug, Clone)] pub struct DestroyCapability { trail_id: ObjectID, @@ -412,6 +423,7 @@ pub struct DestroyCapability { } impl DestroyCapability { + /// Creates a `DestroyCapability` transaction builder payload. pub fn new(trail_id: ObjectID, owner: IotaAddress, capability_id: ObjectID) -> Self { Self { trail_id, @@ -470,6 +482,7 @@ impl Transaction for DestroyCapability { // ===== DestroyInitialAdminCapability ===== +/// Transaction that destroys an initial-admin capability without an auth capability. #[derive(Debug, Clone)] pub struct DestroyInitialAdminCapability { trail_id: ObjectID, @@ -478,6 +491,7 @@ pub struct DestroyInitialAdminCapability { } impl DestroyInitialAdminCapability { + /// Creates a `DestroyInitialAdminCapability` transaction builder payload. pub fn new(trail_id: ObjectID, capability_id: ObjectID) -> Self { Self { trail_id, @@ -535,6 +549,7 @@ impl Transaction for DestroyInitialAdminCapability { // ===== RevokeInitialAdminCapability ===== +/// Transaction that revokes an initial-admin capability. #[derive(Debug, Clone)] pub struct RevokeInitialAdminCapability { trail_id: ObjectID, @@ -545,6 +560,7 @@ pub struct RevokeInitialAdminCapability { } impl RevokeInitialAdminCapability { + /// Creates a `RevokeInitialAdminCapability` transaction builder payload. pub fn new( trail_id: ObjectID, owner: IotaAddress, @@ -614,6 +630,7 @@ impl Transaction for RevokeInitialAdminCapability { } } +/// Transaction that cleans up expired revoked-capability entries. #[derive(Debug, Clone)] pub struct CleanupRevokedCapabilities { trail_id: ObjectID, @@ -622,6 +639,7 @@ pub struct CleanupRevokedCapabilities { } impl CleanupRevokedCapabilities { + /// Creates a `CleanupRevokedCapabilities` transaction builder payload. pub fn new(trail_id: ObjectID, owner: IotaAddress) -> Self { Self { trail_id, diff --git a/audit-trail-rs/src/core/builder.rs b/audit-trail-rs/src/core/builder.rs index f143c176..627fe4c0 100644 --- a/audit-trail-rs/src/core/builder.rs +++ b/audit-trail-rs/src/core/builder.rs @@ -14,11 +14,17 @@ use crate::core::create::CreateTrail; /// Builder for creating an audit trail. #[derive(Debug, Clone, Default)] pub struct AuditTrailBuilder { + /// Initial admin address that should receive the initial admin capability. pub admin: Option, + /// Optional initial record created together with the trail. pub initial_record: Option, + /// Locking rules to apply at creation time. pub locking_config: LockingConfig, + /// Immutable metadata stored once at creation time. pub trail_metadata: Option, + /// Mutable metadata stored on the trail object. pub updatable_metadata: Option, + /// Canonical list of record tags owned by the trail. pub record_tags: HashSet, } diff --git a/audit-trail-rs/src/core/create/mod.rs b/audit-trail-rs/src/core/create/mod.rs index 7365c88c..7dace9ff 100644 --- a/audit-trail-rs/src/core/create/mod.rs +++ b/audit-trail-rs/src/core/create/mod.rs @@ -1,6 +1,8 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +//! Trail-creation transaction types. + mod operations; mod transactions; diff --git a/audit-trail-rs/src/core/create/transactions.rs b/audit-trail-rs/src/core/create/transactions.rs index e13daa8a..c9fa5eba 100644 --- a/audit-trail-rs/src/core/create/transactions.rs +++ b/audit-trail-rs/src/core/create/transactions.rs @@ -19,12 +19,20 @@ use crate::error::Error; /// Output of a create trail transaction. #[derive(Debug, Clone)] pub struct TrailCreated { + /// Newly created trail object ID. pub trail_id: ObjectID, + /// Address that created the trail. pub creator: IotaAddress, + /// Millisecond timestamp emitted by the creation event. pub timestamp: u64, } impl TrailCreated { + /// Loads the newly created trail object from the ledger. + /// + /// # Errors + /// + /// Returns an error if the trail cannot be fetched or deserialized. pub async fn fetch_audit_trail(&self, client: &C) -> Result where C: CoreClientReadOnly + OptionalSync, diff --git a/audit-trail-rs/src/core/locking/mod.rs b/audit-trail-rs/src/core/locking/mod.rs index 9a26e9c3..c90b034a 100644 --- a/audit-trail-rs/src/core/locking/mod.rs +++ b/audit-trail-rs/src/core/locking/mod.rs @@ -1,6 +1,8 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +//! Locking configuration APIs for audit trails. + use iota_interaction::types::base_types::ObjectID; use iota_interaction::{IotaKeySignature, OptionalSync}; use product_common::core_client::CoreClient; @@ -18,6 +20,7 @@ pub use transactions::{UpdateDeleteRecordWindow, UpdateDeleteTrailLock, UpdateLo use self::operations::LockingOps; +/// Locking API scoped to a specific trail. #[derive(Debug, Clone)] pub struct TrailLocking<'a, C> { pub(crate) client: &'a C, @@ -29,6 +32,7 @@ impl<'a, C> TrailLocking<'a, C> { Self { client, trail_id } } + /// Replaces the full locking configuration for the trail. pub fn update(&self, config: LockingConfig) -> TransactionBuilder where C: AuditTrailFull + CoreClient, @@ -38,6 +42,7 @@ impl<'a, C> TrailLocking<'a, C> { TransactionBuilder::new(UpdateLockingConfig::new(self.trail_id, owner, config)) } + /// Updates only the delete-record window. pub fn update_delete_record_window(&self, window: LockingWindow) -> TransactionBuilder where C: AuditTrailFull + CoreClient, @@ -47,6 +52,7 @@ impl<'a, C> TrailLocking<'a, C> { TransactionBuilder::new(UpdateDeleteRecordWindow::new(self.trail_id, owner, window)) } + /// Updates only the delete-trail time lock. pub fn update_delete_trail_lock(&self, lock: TimeLock) -> TransactionBuilder where C: AuditTrailFull + CoreClient, @@ -56,6 +62,7 @@ impl<'a, C> TrailLocking<'a, C> { TransactionBuilder::new(UpdateDeleteTrailLock::new(self.trail_id, owner, lock)) } + /// Updates only the write lock. pub fn update_write_lock(&self, lock: TimeLock) -> TransactionBuilder where C: AuditTrailFull + CoreClient, @@ -65,6 +72,11 @@ impl<'a, C> TrailLocking<'a, C> { TransactionBuilder::new(UpdateWriteLock::new(self.trail_id, owner, lock)) } + /// Returns `true` when the given record is currently locked against deletion. + /// + /// # Errors + /// + /// Returns an error if the lock state cannot be computed from the current on-chain state. pub async fn is_record_locked(&self, sequence_number: u64) -> Result where C: AuditTrailReadOnly, diff --git a/audit-trail-rs/src/core/locking/transactions.rs b/audit-trail-rs/src/core/locking/transactions.rs index b3117d84..68014321 100644 --- a/audit-trail-rs/src/core/locking/transactions.rs +++ b/audit-trail-rs/src/core/locking/transactions.rs @@ -14,6 +14,7 @@ use super::operations::LockingOps; use crate::core::types::{LockingConfig, LockingWindow, TimeLock}; use crate::error::Error; +/// Transaction that replaces the full locking configuration. #[derive(Debug, Clone)] pub struct UpdateLockingConfig { trail_id: ObjectID, @@ -23,6 +24,7 @@ pub struct UpdateLockingConfig { } impl UpdateLockingConfig { + /// Creates an `UpdateLockingConfig` transaction builder payload. pub fn new(trail_id: ObjectID, owner: IotaAddress, config: LockingConfig) -> Self { Self { trail_id, @@ -61,6 +63,7 @@ impl Transaction for UpdateLockingConfig { } } +/// Transaction that updates the delete-record window. #[derive(Debug, Clone)] pub struct UpdateDeleteRecordWindow { trail_id: ObjectID, @@ -70,6 +73,7 @@ pub struct UpdateDeleteRecordWindow { } impl UpdateDeleteRecordWindow { + /// Creates an `UpdateDeleteRecordWindow` transaction builder payload. pub fn new(trail_id: ObjectID, owner: IotaAddress, window: LockingWindow) -> Self { Self { trail_id, @@ -108,6 +112,7 @@ impl Transaction for UpdateDeleteRecordWindow { } } +/// Transaction that updates the delete-trail lock. #[derive(Debug, Clone)] pub struct UpdateDeleteTrailLock { trail_id: ObjectID, @@ -117,6 +122,7 @@ pub struct UpdateDeleteTrailLock { } impl UpdateDeleteTrailLock { + /// Creates an `UpdateDeleteTrailLock` transaction builder payload. pub fn new(trail_id: ObjectID, owner: IotaAddress, lock: TimeLock) -> Self { Self { trail_id, @@ -155,6 +161,7 @@ impl Transaction for UpdateDeleteTrailLock { } } +/// Transaction that updates the write lock. #[derive(Debug, Clone)] pub struct UpdateWriteLock { trail_id: ObjectID, @@ -164,6 +171,7 @@ pub struct UpdateWriteLock { } impl UpdateWriteLock { + /// Creates an `UpdateWriteLock` transaction builder payload. pub fn new(trail_id: ObjectID, owner: IotaAddress, lock: TimeLock) -> Self { Self { trail_id, diff --git a/audit-trail-rs/src/core/mod.rs b/audit-trail-rs/src/core/mod.rs index 36584101..c5b3db15 100644 --- a/audit-trail-rs/src/core/mod.rs +++ b/audit-trail-rs/src/core/mod.rs @@ -1,14 +1,33 @@ // Copyright 2020-2025 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -//! Core types and builders for audit trail. +//! Core handles, builders, transactions, and domain types for audit trails. +//! +//! The modules in this namespace make up the main domain-facing API: +//! +//! - [`access`] exposes role and capability management +//! - [`builder`] configures trail creation +//! - [`create`] contains the creation transaction types +//! - [`locking`] manages trail locking rules +//! - [`records`] reads and mutates trail records +//! - [`tags`] manages the trail-owned record-tag registry +//! - [`trail`] provides the high-level typed handle bound to a specific trail +//! - [`types`] contains serializable value types shared across the crate +/// Role and capability management APIs. pub mod access; +/// Builder used to configure trail creation. pub mod builder; +/// Trail-creation transaction types. pub mod create; pub(crate) mod internal; +/// Locking configuration APIs. pub mod locking; +/// Record read and mutation APIs. pub mod records; +/// Trail-scoped record-tag management APIs. pub mod tags; +/// High-level trail handle types. pub mod trail; +/// Shared domain and event types. pub mod types; diff --git a/audit-trail-rs/src/core/records/mod.rs b/audit-trail-rs/src/core/records/mod.rs index 53a93d4c..eaefef9c 100644 --- a/audit-trail-rs/src/core/records/mod.rs +++ b/audit-trail-rs/src/core/records/mod.rs @@ -1,6 +1,8 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +//! Record read and mutation APIs for audit trails. + use std::collections::{BTreeMap, HashMap}; use iota_interaction::move_core_types::annotated_value::MoveValue; @@ -29,6 +31,7 @@ use self::operations::RecordsOps; const MAX_LIST_PAGE_LIMIT: usize = 1_000; +/// Record API scoped to a specific trail. #[derive(Debug, Clone)] pub struct TrailRecords<'a, C, D = Data> { pub(crate) client: &'a C, @@ -45,6 +48,11 @@ impl<'a, C, D> TrailRecords<'a, C, D> { } } + /// Loads a single record by sequence number. + /// + /// # Errors + /// + /// Returns an error if the record cannot be loaded or deserialized. pub async fn get(&self, sequence_number: u64) -> Result, Error> where C: AuditTrailReadOnly, @@ -54,6 +62,7 @@ impl<'a, C, D> TrailRecords<'a, C, D> { self.client.execute_read_only_transaction(tx).await } + /// Builds a transaction that appends a record to the trail. pub fn add(&self, data: D, metadata: Option, tag: Option) -> TransactionBuilder where C: AuditTrailFull + CoreClient, @@ -64,6 +73,7 @@ impl<'a, C, D> TrailRecords<'a, C, D> { TransactionBuilder::new(AddRecord::new(self.trail_id, owner, data.into(), metadata, tag)) } + /// Builds a transaction that deletes a single record. pub fn delete(&self, sequence_number: u64) -> TransactionBuilder where C: AuditTrailFull + CoreClient, @@ -73,6 +83,7 @@ impl<'a, C, D> TrailRecords<'a, C, D> { TransactionBuilder::new(DeleteRecord::new(self.trail_id, owner, sequence_number)) } + /// Builds a transaction that deletes up to `limit` records in one operation. pub fn delete_records_batch(&self, limit: u64) -> TransactionBuilder where C: AuditTrailFull + CoreClient, @@ -82,6 +93,11 @@ impl<'a, C, D> TrailRecords<'a, C, D> { TransactionBuilder::new(DeleteRecordsBatch::new(self.trail_id, owner, limit)) } + /// Placeholder for a future correction helper. + /// + /// # Errors + /// + /// Always returns [`Error::NotImplemented`]. pub async fn correct(&self, _replaces: Vec, _data: D, _metadata: Option) -> Result<(), Error> where C: AuditTrailFull, @@ -89,6 +105,11 @@ impl<'a, C, D> TrailRecords<'a, C, D> { Err(Error::NotImplemented("TrailRecords::correct")) } + /// Returns the number of records currently stored in the trail. + /// + /// # Errors + /// + /// Returns an error if the count cannot be computed from the current on-chain state. pub async fn record_count(&self) -> Result where C: AuditTrailReadOnly, diff --git a/audit-trail-rs/src/core/records/transactions.rs b/audit-trail-rs/src/core/records/transactions.rs index 74007aa2..af2d4ef9 100644 --- a/audit-trail-rs/src/core/records/transactions.rs +++ b/audit-trail-rs/src/core/records/transactions.rs @@ -16,17 +16,24 @@ use crate::error::Error; // ===== AddRecord ===== +/// Transaction that appends a record to a trail. #[derive(Debug, Clone)] pub struct AddRecord { + /// Trail object ID that will receive the record. pub trail_id: ObjectID, + /// Address authorizing the write. pub owner: IotaAddress, + /// Record payload to append. pub data: Data, + /// Optional application-defined metadata. pub metadata: Option, + /// Optional trail-owned tag to attach to the record. pub tag: Option, cached_ptb: OnceCell, } impl AddRecord { + /// Creates an `AddRecord` transaction builder payload. pub fn new( trail_id: ObjectID, owner: IotaAddress, @@ -101,15 +108,20 @@ impl Transaction for AddRecord { // ===== DeleteRecord ===== +/// Transaction that deletes a single record. #[derive(Debug, Clone)] pub struct DeleteRecord { + /// Trail object ID containing the record. pub trail_id: ObjectID, + /// Address authorizing the deletion. pub owner: IotaAddress, + /// Sequence number of the record to delete. pub sequence_number: u64, cached_ptb: OnceCell, } impl DeleteRecord { + /// Creates a `DeleteRecord` transaction builder payload. pub fn new(trail_id: ObjectID, owner: IotaAddress, sequence_number: u64) -> Self { Self { trail_id, @@ -168,15 +180,20 @@ impl Transaction for DeleteRecord { // ===== DeleteRecordsBatch ===== +/// Transaction that deletes multiple records in a batch operation. #[derive(Debug, Clone)] pub struct DeleteRecordsBatch { + /// Trail object ID containing the records. pub trail_id: ObjectID, + /// Address authorizing the deletion. pub owner: IotaAddress, + /// Maximum number of records to delete in this batch. pub limit: u64, cached_ptb: OnceCell, } impl DeleteRecordsBatch { + /// Creates a `DeleteRecordsBatch` transaction builder payload. pub fn new(trail_id: ObjectID, owner: IotaAddress, limit: u64) -> Self { Self { trail_id, diff --git a/audit-trail-rs/src/core/tags/mod.rs b/audit-trail-rs/src/core/tags/mod.rs index 3049b2f5..0bb995f8 100644 --- a/audit-trail-rs/src/core/tags/mod.rs +++ b/audit-trail-rs/src/core/tags/mod.rs @@ -1,6 +1,8 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +//! Record-tag registry APIs for audit trails. + use iota_interaction::types::base_types::ObjectID; use iota_interaction::{IotaKeySignature, OptionalSync}; use product_common::core_client::CoreClient; @@ -14,6 +16,7 @@ mod transactions; pub use transactions::{AddRecordTag, RemoveRecordTag}; +/// Tag-registry API scoped to a specific trail. #[derive(Debug, Clone)] pub struct TrailTags<'a, C> { pub(crate) client: &'a C, diff --git a/audit-trail-rs/src/core/tags/transactions.rs b/audit-trail-rs/src/core/tags/transactions.rs index 7f310926..f10976bf 100644 --- a/audit-trail-rs/src/core/tags/transactions.rs +++ b/audit-trail-rs/src/core/tags/transactions.rs @@ -13,6 +13,7 @@ use tokio::sync::OnceCell; use super::operations::TagsOps; use crate::error::Error; +/// Transaction that adds a record tag to the trail registry. #[derive(Debug, Clone)] pub struct AddRecordTag { trail_id: ObjectID, @@ -22,6 +23,7 @@ pub struct AddRecordTag { } impl AddRecordTag { + /// Creates an `AddRecordTag` transaction builder payload. pub fn new(trail_id: ObjectID, owner: IotaAddress, tag: String) -> Self { Self { trail_id, @@ -60,6 +62,7 @@ impl Transaction for AddRecordTag { } } +/// Transaction that removes a record tag from the trail registry. #[derive(Debug, Clone)] pub struct RemoveRecordTag { trail_id: ObjectID, @@ -69,6 +72,7 @@ pub struct RemoveRecordTag { } impl RemoveRecordTag { + /// Creates a `RemoveRecordTag` transaction builder payload. pub fn new(trail_id: ObjectID, owner: IotaAddress, tag: String) -> Self { Self { trail_id, diff --git a/audit-trail-rs/src/core/trail.rs b/audit-trail-rs/src/core/trail.rs index 00c84327..a4521949 100644 --- a/audit-trail-rs/src/core/trail.rs +++ b/audit-trail-rs/src/core/trail.rs @@ -1,6 +1,8 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +//! High-level trail handle types and trail-scoped transactions. + use iota_interaction::types::base_types::ObjectID; use iota_interaction::types::transaction::ProgrammableTransaction; use iota_interaction::{IotaKeySignature, OptionalSync}; @@ -27,6 +29,7 @@ pub use transactions::{DeleteAuditTrail, Migrate, UpdateMetadata}; #[cfg_attr(not(feature = "send-sync"), async_trait::async_trait(?Send))] #[cfg_attr(feature = "send-sync", async_trait::async_trait)] pub trait AuditTrailReadOnly: CoreClientReadOnly + OptionalSync { + /// Executes a read-only programmable transaction and decodes the first return value. async fn execute_read_only_transaction(&self, tx: ProgrammableTransaction) -> Result; } @@ -36,6 +39,10 @@ pub trait AuditTrailReadOnly: CoreClientReadOnly + OptionalSync { pub trait AuditTrailFull: AuditTrailReadOnly {} /// A typed handle bound to a specific audit trail and client. +/// +/// `AuditTrailHandle` is the main trail-scoped entry point. It keeps the trail ID together with +/// the client so that record, locking, access-control, tag, and metadata operations can all hang +/// off one typed value. #[derive(Debug, Clone)] pub struct AuditTrailHandle<'a, C> { pub(crate) client: &'a C, @@ -48,6 +55,8 @@ impl<'a, C> AuditTrailHandle<'a, C> { } /// Loads the full on-chain audit trail object. + /// + /// Each call fetches a fresh snapshot from chain state. pub async fn get(&self) -> Result where C: AuditTrailReadOnly, @@ -87,18 +96,30 @@ impl<'a, C> AuditTrailHandle<'a, C> { TransactionBuilder::new(DeleteAuditTrail::new(self.trail_id, owner)) } + /// Returns the record API scoped to this trail. + /// + /// Use this for record reads and record-oriented transaction builders. pub fn records(&self) -> TrailRecords<'a, C, Data> { TrailRecords::new(self.client, self.trail_id) } + /// Returns the locking API scoped to this trail. + /// + /// Use this for checking and updating trail-level locking rules. pub fn locking(&self) -> TrailLocking<'a, C> { TrailLocking::new(self.client, self.trail_id) } + /// Returns the access-control API scoped to this trail. + /// + /// Use this for roles, capabilities, and access-policy updates. pub fn access(&self) -> TrailAccess<'a, C> { TrailAccess::new(self.client, self.trail_id) } + /// Returns the tag-registry API scoped to this trail. + /// + /// Use this for managing the set of tags available to records in this trail. pub fn tags(&self) -> TrailTags<'a, C> { TrailTags::new(self.client, self.trail_id) } diff --git a/audit-trail-rs/src/core/trail/transactions.rs b/audit-trail-rs/src/core/trail/transactions.rs index 4f12359c..34174bdc 100644 --- a/audit-trail-rs/src/core/trail/transactions.rs +++ b/audit-trail-rs/src/core/trail/transactions.rs @@ -14,6 +14,7 @@ use super::operations::TrailOps; use crate::core::types::{AuditTrailDeleted, Event}; use crate::error::Error; +/// Transaction that migrates a trail to the latest supported package version. #[derive(Debug, Clone)] pub struct Migrate { trail_id: ObjectID, @@ -22,6 +23,7 @@ pub struct Migrate { } impl Migrate { + /// Creates a `Migrate` transaction builder payload. pub fn new(trail_id: ObjectID, owner: IotaAddress) -> Self { Self { trail_id, @@ -59,6 +61,7 @@ impl Transaction for Migrate { } } +/// Transaction that updates mutable trail metadata. #[derive(Debug, Clone)] pub struct UpdateMetadata { trail_id: ObjectID, @@ -68,6 +71,7 @@ pub struct UpdateMetadata { } impl UpdateMetadata { + /// Creates an `UpdateMetadata` transaction builder payload. pub fn new(trail_id: ObjectID, owner: IotaAddress, metadata: Option) -> Self { Self { trail_id, @@ -106,6 +110,7 @@ impl Transaction for UpdateMetadata { } } +/// Transaction that deletes an empty trail. #[derive(Debug, Clone)] pub struct DeleteAuditTrail { trail_id: ObjectID, @@ -114,6 +119,7 @@ pub struct DeleteAuditTrail { } impl DeleteAuditTrail { + /// Creates a `DeleteAuditTrail` transaction builder payload. pub fn new(trail_id: ObjectID, owner: IotaAddress) -> Self { Self { trail_id, diff --git a/audit-trail-rs/src/core/types/audit_trail.rs b/audit-trail-rs/src/core/types/audit_trail.rs index 06d62392..962a3218 100644 --- a/audit-trail-rs/src/core/types/audit_trail.rs +++ b/audit-trail-rs/src/core/types/audit_trail.rs @@ -19,29 +19,36 @@ use crate::core::internal::move_collections::deserialize_vec_map; use crate::core::internal::tx; use crate::error::Error; +/// Registry of trail-owned record tags. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct TagRegistry { + /// Mapping from tag name to usage count. #[serde(deserialize_with = "deserialize_vec_map")] pub tag_map: HashMap, } impl TagRegistry { + /// Returns the number of registered tags. pub fn len(&self) -> usize { self.tag_map.len() } + /// Returns `true` when no tags are registered. pub fn is_empty(&self) -> bool { self.tag_map.is_empty() } + /// Returns `true` when the registry contains the given tag. pub fn contains_key(&self, tag: &str) -> bool { self.tag_map.contains_key(tag) } + /// Returns the usage count for a tag. pub fn get(&self, tag: &str) -> Option<&u64> { self.tag_map.get(tag) } + /// Iterates over tag names and usage counts. pub fn iter(&self) -> impl Iterator { self.tag_map.iter() } @@ -50,27 +57,41 @@ impl TagRegistry { /// An audit trail stored on-chain. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct OnChainAuditTrail { + /// Unique object ID of the trail. pub id: UID, + /// Address that created the trail. pub creator: IotaAddress, + /// Millisecond timestamp at which the trail was created. pub created_at: u64, + /// Current record sequence number cursor. pub sequence_number: u64, + /// Linked table containing the trail records. pub records: LinkedTable, + /// Registry of allowed record tags. pub tags: TagRegistry, + /// Active locking rules for the trail. pub locking_config: LockingConfig, + /// Role and capability configuration for the trail. pub roles: RoleMap, + /// Metadata fixed at creation time. pub immutable_metadata: Option, + /// Metadata that can be updated after creation. pub updatable_metadata: Option, + /// On-chain package version of the trail object. pub version: u64, } /// Metadata set at trail creation and never updated. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ImmutableMetadata { + /// Human-readable trail name. pub name: String, + /// Optional human-readable description. pub description: Option, } impl ImmutableMetadata { + /// Creates immutable metadata for a trail. pub fn new(name: String, description: Option) -> Self { Self { name, description } } diff --git a/audit-trail-rs/src/core/types/event.rs b/audit-trail-rs/src/core/types/event.rs index d52fa828..988f43bf 100644 --- a/audit-trail-rs/src/core/types/event.rs +++ b/audit-trail-rs/src/core/types/event.rs @@ -13,102 +13,157 @@ use super::{Permission, PermissionSet, RoleTags}; /// Generic wrapper for audit trail events. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Event { + /// Parsed event payload. #[serde(flatten)] pub data: D, } +/// Event emitted when a trail is created. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AuditTrailCreated { + /// Newly created trail object ID. pub trail_id: ObjectID, + /// Address that created the trail. pub creator: IotaAddress, + /// Millisecond event timestamp. #[serde(deserialize_with = "deserialize_number_from_string")] pub timestamp: u64, } +/// Event emitted when a trail is deleted. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AuditTrailDeleted { + /// Deleted trail object ID. pub trail_id: ObjectID, + /// Millisecond event timestamp. #[serde(deserialize_with = "deserialize_number_from_string")] pub timestamp: u64, } +/// Event emitted when a record is added. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RecordAdded { + /// Trail object ID receiving the new record. pub trail_id: ObjectID, + /// Sequence number assigned to the new record. #[serde(deserialize_with = "deserialize_number_from_string")] pub sequence_number: u64, + /// Address that added the record. pub added_by: IotaAddress, + /// Millisecond event timestamp. #[serde(deserialize_with = "deserialize_number_from_string")] pub timestamp: u64, } +/// Event emitted when a record is deleted. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RecordDeleted { + /// Trail object ID from which the record was deleted. pub trail_id: ObjectID, + /// Sequence number of the deleted record. #[serde(deserialize_with = "deserialize_number_from_string")] pub sequence_number: u64, + /// Address that deleted the record. pub deleted_by: IotaAddress, + /// Millisecond event timestamp. #[serde(deserialize_with = "deserialize_number_from_string")] pub timestamp: u64, } +/// Event emitted when a capability is issued. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CapabilityIssued { + /// Trail object ID protected by the capability. pub target_key: ObjectID, + /// Newly created capability object ID. pub capability_id: ObjectID, + /// Role granted by the capability. pub role: String, + /// Address receiving the capability, if one is assigned. pub issued_to: Option, + /// Millisecond timestamp at which the capability becomes valid. #[serde(deserialize_with = "deserialize_option_number_from_string")] pub valid_from: Option, + /// Millisecond timestamp at which the capability expires. #[serde(deserialize_with = "deserialize_option_number_from_string")] pub valid_until: Option, } +/// Event emitted when a capability object is destroyed. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CapabilityDestroyed { + /// Trail object ID protected by the capability. pub target_key: ObjectID, + /// Destroyed capability object ID. pub capability_id: ObjectID, + /// Role granted by the capability. pub role: String, + /// Address that held the capability, if any. pub issued_to: Option, + /// Millisecond timestamp at which the capability became valid. #[serde(deserialize_with = "deserialize_option_number_from_string")] pub valid_from: Option, + /// Millisecond timestamp at which the capability expired. #[serde(deserialize_with = "deserialize_option_number_from_string")] pub valid_until: Option, } +/// Event emitted when a capability is revoked. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CapabilityRevoked { + /// Trail object ID protected by the capability. pub target_key: ObjectID, + /// Revoked capability object ID. pub capability_id: ObjectID, + /// Millisecond timestamp retained for denylist cleanup. #[serde(deserialize_with = "deserialize_number_from_string")] pub valid_until: u64, } +/// Event emitted when a role is created. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct RoleCreated { + /// Trail object ID that owns the role. pub trail_id: ObjectID, + /// Role name. pub role: String, + /// Permissions granted by the new role. pub permissions: PermissionSet, + /// Optional record-tag restrictions stored as role data. pub data: Option, + /// Address that created the role. pub created_by: IotaAddress, + /// Millisecond event timestamp. pub timestamp: u64, } +/// Event emitted when a role is updated. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct RoleUpdated { + /// Trail object ID that owns the role. pub trail_id: ObjectID, + /// Role name. pub role: String, + /// Updated permissions for the role. pub permissions: PermissionSet, + /// Updated record-tag restrictions, if any. pub data: Option, + /// Address that updated the role. pub updated_by: IotaAddress, + /// Millisecond event timestamp. pub timestamp: u64, } +/// Event emitted when a role is deleted. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct RoleDeleted { + /// Trail object ID that owned the role. pub trail_id: ObjectID, + /// Role name. pub role: String, + /// Address that deleted the role. pub deleted_by: IotaAddress, + /// Millisecond event timestamp. pub timestamp: u64, } diff --git a/audit-trail-rs/src/core/types/locking.rs b/audit-trail-rs/src/core/types/locking.rs index 1986c811..eb03205a 100644 --- a/audit-trail-rs/src/core/types/locking.rs +++ b/audit-trail-rs/src/core/types/locking.rs @@ -13,8 +13,11 @@ use crate::error::Error; /// Locking configuration for the audit trail. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] pub struct LockingConfig { + /// Delete-window policy applied to individual records. pub delete_record_window: LockingWindow, + /// Time lock that gates deletion of the entire trail. pub delete_trail_lock: TimeLock, + /// Time lock that gates record writes. pub write_lock: TimeLock, } @@ -47,10 +50,15 @@ impl LockingConfig { /// Must match `tf_components::timelock::TimeLock` variant order for BCS compatibility. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] pub enum TimeLock { + /// Unlocks at the given Unix timestamp in seconds. UnlockAt(u32), + /// Unlocks at the given Unix timestamp in milliseconds. UnlockAtMs(u64), + /// Remains locked until the protected object is explicitly destroyed. UntilDestroyed, + /// Represents an always-locked state. Infinite, + /// Disables the time lock. #[default] None, } @@ -110,12 +118,17 @@ impl TimeLock { /// Defines a locking window (none, time based, or count based). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] pub enum LockingWindow { + /// No delete window is enforced. #[default] None, + /// Records may be deleted only within the given number of seconds since creation. TimeBased { + /// Window size in seconds. seconds: u64, }, + /// Records may be deleted only within the first `count` subsequent records. CountBased { + /// Number of subsequent records after which deletion is no longer allowed. count: u64, }, } diff --git a/audit-trail-rs/src/core/types/mod.rs b/audit-trail-rs/src/core/types/mod.rs index ef9dfdcc..301427fd 100644 --- a/audit-trail-rs/src/core/types/mod.rs +++ b/audit-trail-rs/src/core/types/mod.rs @@ -1,13 +1,23 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -//! Core data types for audit trail. +//! Shared serializable domain types for audit trails. +//! +//! These types mirror the on-chain data model closely enough to deserialize ledger state and +//! events, while still providing a Rust-native API for builders, permission management, and +//! higher-level client flows. +/// On-chain trail metadata types. pub mod audit_trail; +/// Event payload types emitted by audit-trail transactions. pub mod event; +/// Locking configuration types. pub mod locking; +/// Permission and permission-set types. pub mod permission; +/// Record payload and pagination types. pub mod record; +/// Role, capability, and role-tag types. pub mod role_map; pub use audit_trail::*; diff --git a/audit-trail-rs/src/core/types/permission.rs b/audit-trail-rs/src/core/types/permission.rs index a7b49ac7..57a50906 100644 --- a/audit-trail-rs/src/core/types/permission.rs +++ b/audit-trail-rs/src/core/types/permission.rs @@ -13,27 +13,46 @@ use serde::{Deserialize, Serialize}; use crate::error::Error; -/// Permission enum matching the Move permission module. +/// Audit-trail permission variants mirrored from the Move permission module. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)] pub enum Permission { + /// Allows deleting the entire trail. DeleteAuditTrail, + /// Allows deleting all records in batch form. DeleteAllRecords, + /// Allows adding records. AddRecord, + /// Allows deleting individual records. DeleteRecord, + /// Allows creating correction records. CorrectRecord, + /// Allows updating the full locking configuration. UpdateLockingConfig, + /// Allows updating the delete-record window. UpdateLockingConfigForDeleteRecord, + /// Allows updating the delete-trail time lock. UpdateLockingConfigForDeleteTrail, + /// Allows updating the write lock. UpdateLockingConfigForWrite, + /// Allows creating roles. AddRoles, + /// Allows updating roles. UpdateRoles, + /// Allows deleting roles. DeleteRoles, + /// Allows issuing capabilities. AddCapabilities, + /// Allows revoking capabilities. RevokeCapabilities, + /// Allows updating mutable metadata. UpdateMetadata, + /// Allows deleting mutable metadata. DeleteMetadata, + /// Allows migrating the trail to a newer package version. Migrate, + /// Allows adding trail-owned record tags. AddRecordTags, + /// Allows deleting trail-owned record tags. DeleteRecordTags, } @@ -75,9 +94,10 @@ impl Permission { } } -/// Convenience wrapper for permission sets. +/// Convenience wrapper around a set of [`Permission`] values. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] pub struct PermissionSet { + /// Permissions granted by this set. pub permissions: HashSet, } @@ -92,6 +112,7 @@ impl PermissionSet { Ok(ptb.command(Command::MakeMoveVec(Some(permission_type.into()), permission_args))) } + /// Returns the recommended role-administration permissions. pub fn admin_permissions() -> Self { Self { permissions: HashSet::from([ @@ -106,6 +127,7 @@ impl PermissionSet { } } + /// Returns the permissions needed to administer records. pub fn record_admin_permissions() -> Self { Self { permissions: HashSet::from([ @@ -116,6 +138,7 @@ impl PermissionSet { } } + /// Returns the permissions needed to administer locking rules. pub fn locking_admin_permissions() -> Self { Self { permissions: HashSet::from([ @@ -127,24 +150,28 @@ impl PermissionSet { } } + /// Returns the permissions needed to administer roles. pub fn role_admin_permissions() -> Self { Self { permissions: HashSet::from([Permission::AddRoles, Permission::UpdateRoles, Permission::DeleteRoles]), } } + /// Returns the permissions needed to administer record tags. pub fn tag_admin_permissions() -> Self { Self { permissions: HashSet::from([Permission::AddRecordTags, Permission::DeleteRecordTags]), } } + /// Returns the permissions needed to issue and revoke capabilities. pub fn cap_admin_permissions() -> Self { Self { permissions: HashSet::from_iter(vec![Permission::AddCapabilities, Permission::RevokeCapabilities]), } } + /// Returns the permissions needed to administer mutable metadata. pub fn metadata_admin_permissions() -> Self { Self { permissions: HashSet::from_iter(vec![Permission::UpdateMetadata, Permission::DeleteMetadata]), diff --git a/audit-trail-rs/src/core/types/record.rs b/audit-trail-rs/src/core/types/record.rs index d20f743d..6b85c227 100644 --- a/audit-trail-rs/src/core/types/record.rs +++ b/audit-trail-rs/src/core/types/record.rs @@ -17,32 +17,58 @@ use crate::error::Error; /// Page of records loaded through linked-table traversal. #[derive(Debug, Clone)] pub struct PaginatedRecord { + /// Records included in the current page, keyed by sequence number. pub records: BTreeMap>, + /// Cursor to pass to the next [`TrailRecords::list_page`](crate::core::records::TrailRecords::list_page) call. pub next_cursor: Option, + /// Indicates whether another page may be available. pub has_next_page: bool, } /// A single record in the audit trail. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Record { + /// Record payload stored on-chain. pub data: D, + /// Optional application-defined metadata. pub metadata: Option, + /// Optional trail-owned tag attached to the record. pub tag: Option, + /// Monotonic record sequence number inside the trail. pub sequence_number: u64, + /// Address that added the record. pub added_by: IotaAddress, + /// Millisecond timestamp at which the record was added. pub added_at: u64, + /// Correction relationships for this record. pub correction: RecordCorrection, } /// Input used when creating a trail with an initial record. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct InitialRecord { + /// Initial payload to store in the trail. pub data: D, + /// Optional application-defined metadata. pub metadata: Option, + /// Optional initial tag from the trail-owned registry. pub tag: Option, } impl InitialRecord { + /// Creates a new initial record. + /// + /// # Examples + /// + /// ```rust + /// use audit_trail::core::types::{Data, InitialRecord}; + /// + /// let record = InitialRecord::new(Data::text("hello"), Some("seed".to_string()), Some("inbox".to_string())); + /// + /// assert_eq!(record.data, Data::text("hello")); + /// assert_eq!(record.metadata.as_deref(), Some("seed")); + /// assert_eq!(record.tag.as_deref(), Some("inbox")); + /// ``` pub fn new(data: impl Into, metadata: Option, tag: Option) -> Self { Self { data: data.into(), @@ -78,11 +104,14 @@ impl InitialRecord { /// Bidirectional correction tracking for audit records. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] pub struct RecordCorrection { + /// Sequence numbers that this record supersedes. pub replaces: HashSet, + /// Sequence number of the record that supersedes this one, if any. pub is_replaced_by: Option, } impl RecordCorrection { + /// Creates a correction value that replaces the given sequence numbers. pub fn with_replaces(replaces: HashSet) -> Self { Self { replaces, @@ -90,10 +119,24 @@ impl RecordCorrection { } } + /// Returns `true` when this record supersedes at least one earlier record. + /// + /// # Examples + /// + /// ```rust + /// use std::collections::HashSet; + /// + /// use audit_trail::core::types::RecordCorrection; + /// + /// let correction = RecordCorrection::with_replaces(HashSet::from([1, 2])); + /// + /// assert!(correction.is_correction()); + /// ``` pub fn is_correction(&self) -> bool { !self.replaces.is_empty() } + /// Returns `true` when this record has itself been replaced by a later record. pub fn is_replaced(&self) -> bool { self.is_replaced_by.is_some() } @@ -102,7 +145,9 @@ impl RecordCorrection { /// Supported record data types. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum Data { + /// Arbitrary binary payload. Bytes(Vec), + /// UTF-8 text payload. Text(String), } @@ -153,11 +198,27 @@ impl Data { } /// Creates a new `Data` from bytes. + /// + /// # Examples + /// + /// ```rust + /// use audit_trail::core::types::Data; + /// + /// assert_eq!(Data::bytes([1_u8, 2, 3]), Data::Bytes(vec![1, 2, 3])); + /// ``` pub fn bytes(data: impl Into>) -> Self { Self::Bytes(data.into()) } /// Creates a new `Data` from text. + /// + /// # Examples + /// + /// ```rust + /// use audit_trail::core::types::Data; + /// + /// assert_eq!(Data::text("hello"), Data::Text("hello".to_string())); + /// ``` pub fn text(data: impl Into) -> Self { Self::Text(data.into()) } diff --git a/audit-trail-rs/src/core/types/role_map.rs b/audit-trail-rs/src/core/types/role_map.rs index a0b241fe..084ca446 100644 --- a/audit-trail-rs/src/core/types/role_map.rs +++ b/audit-trail-rs/src/core/types/role_map.rs @@ -17,46 +17,66 @@ use super::permission::Permission; use crate::core::internal::move_collections::{deserialize_vec_map, deserialize_vec_set}; use crate::core::internal::tx; use crate::error::Error; + +/// On-chain role and capability configuration for a trail. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RoleMap { + /// Trail object ID that this role map protects. pub target_key: ObjectID, + /// Role definitions keyed by role name. #[serde(deserialize_with = "deserialize_vec_map")] pub roles: HashMap, + /// Reserved role name used for initial-admin capabilities. pub initial_admin_role_name: String, + /// Denylist of revoked capability IDs. pub revoked_capabilities: LinkedTable, + /// Capability IDs currently recognized as initial-admin capabilities. #[serde(deserialize_with = "deserialize_vec_set")] pub initial_admin_cap_ids: HashSet, + /// Permissions required to administer roles. pub role_admin_permissions: RoleAdminPermissions, + /// Permissions required to administer capabilities. pub capability_admin_permissions: CapabilityAdminPermissions, } +/// Role definition stored in the trail role map. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Role { + /// Permissions granted by the role. #[serde(deserialize_with = "deserialize_vec_set")] pub permissions: HashSet, + /// Optional role-scoped record-tag restrictions. pub data: Option, } /// Defines the permissions required to administer roles in this RoleMap. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RoleAdminPermissions { + /// Permission required to create roles. pub add: Permission, + /// Permission required to delete roles. pub delete: Permission, + /// Permission required to update roles. pub update: Permission, } /// Defines the permissions required to administer capabilities in this RoleMap. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CapabilityAdminPermissions { + /// Permission required to issue capabilities. pub add: Permission, + /// Permission required to revoke capabilities. pub revoke: Permission, } /// Capability issuance options used by the role-based API. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct CapabilityIssueOptions { + /// Address that should own the capability, if any. pub issued_to: Option, + /// Millisecond timestamp at which the capability becomes valid. pub valid_from_ms: Option, + /// Millisecond timestamp at which the capability expires. pub valid_until_ms: Option, } @@ -66,11 +86,13 @@ pub struct CapabilityIssueOptions { /// Move `record_tags::RoleTags` type. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] pub struct RoleTags { + /// Allowlisted record tags for the role. #[serde(deserialize_with = "deserialize_vec_set")] pub tags: HashSet, } impl RoleTags { + /// Creates role-tag restrictions from an iterator of tag names. pub fn new(tags: I) -> Self where I: IntoIterator, @@ -81,6 +103,7 @@ impl RoleTags { } } + /// Returns `true` when the given tag is allowed for the role. pub fn allows(&self, tag: &str) -> bool { self.tags.contains(tag) } @@ -107,11 +130,17 @@ impl RoleTags { /// Capability data returned by the Move capability module. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Capability { + /// Capability object ID. pub id: UID, + /// Trail object ID protected by the capability. pub target_key: ObjectID, + /// Role granted by the capability. pub role: String, + /// Capability holder, if the capability is assigned to an address. pub issued_to: Option, + /// Millisecond timestamp at which the capability becomes valid. pub valid_from: Option, + /// Millisecond timestamp at which the capability expires. pub valid_until: Option, } diff --git a/audit-trail-rs/src/error.rs b/audit-trail-rs/src/error.rs index 79f75834..af81958a 100644 --- a/audit-trail-rs/src/error.rs +++ b/audit-trail-rs/src/error.rs @@ -1,34 +1,36 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +//! Error types returned by the audit-trail public API. + use crate::iota_interaction_adapter::AdapterError; -/// Errors that can occur when managing Audit Trails +/// Errors that can occur when reading or mutating audit trails. #[derive(Debug, thiserror::Error, strum::IntoStaticStr)] #[non_exhaustive] pub enum Error { - /// Caused by invalid keys. + /// Returned when a signer key or public key cannot be derived or validated. #[error("invalid key: {0}")] InvalidKey(String), - /// Config is invalid. + /// Returned when client configuration or package-ID configuration is invalid. #[error("invalid config: {0}")] InvalidConfig(String), - /// An error caused by either a connection issue or an invalid RPC call. + /// Returned when an RPC request fails. #[error("RPC error: {0}")] RpcError(String), - /// The provided IOTA Client returned an error + /// Error returned by the underlying IOTA client adapter. #[error("IOTA client error: {0}")] IotaClient(#[from] AdapterError), - /// Generic error + /// Generic catch-all error for crate-specific failures that do not fit a narrower variant. #[error("{0}")] GenericError(String), /// Placeholder for unimplemented API surface. #[error("not implemented: {0}")] NotImplemented(&'static str), - /// Failed to parse tag + /// Returned when a Move tag cannot be parsed. #[error("Failed to parse tag: {0}")] FailedToParseTag(String), - /// Invalid argument + /// Returned when an argument is semantically invalid. #[error("Invalid argument: {0}")] InvalidArgument(String), /// The response from the IOTA node API was not in the expected format. @@ -37,7 +39,7 @@ pub enum Error { /// Failed to deserialize data using BCS. #[error("BCS deserialization error: {0}")] DeserializationError(#[from] bcs::Error), - /// The response from the IOTA node API was not in the expected format. + /// The transaction response from the IOTA node API was not in the expected format. #[error("unexpected transaction response: {0}")] TransactionUnexpectedResponse(String), } diff --git a/audit-trail-rs/src/lib.rs b/audit-trail-rs/src/lib.rs index 2c8f8644..82f6f73e 100644 --- a/audit-trail-rs/src/lib.rs +++ b/audit-trail-rs/src/lib.rs @@ -1,13 +1,21 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +#![doc = include_str!("../README.md")] +#![warn(missing_docs, rustdoc::all)] + +/// Client wrappers for read-only and signing access to audit trails. pub mod client; +/// Core handles, builders, transactions, and domain types. pub mod core; +/// Error types returned by the public API. pub mod error; pub(crate) mod iota_interaction_adapter; pub(crate) mod package; +/// A signing audit-trail client that can build write transactions. pub use client::full_client::AuditTrailClient; +/// Read-only client types and package override configuration. pub use client::read_only::{AuditTrailClientReadOnly, PackageOverrides}; /// HTTP utilities to implement the trait [HttpClient](product_common::http_client::HttpClient). #[cfg(feature = "gas-station")] diff --git a/bindings/wasm/audit_trail_wasm/README.md b/bindings/wasm/audit_trail_wasm/README.md index b06586e7..7a9fcf75 100644 --- a/bindings/wasm/audit_trail_wasm/README.md +++ b/bindings/wasm/audit_trail_wasm/README.md @@ -1,22 +1,66 @@ -# IOTA Audit Trail WASM Library +# `audit_trail_wasm` -`audit_trail_wasm` provides the Rust-to-WASM bindings for the `audit_trail` crate and is published to JavaScript consumers as `@iota/audit-trail`. +`audit_trail_wasm` exposes the `audit_trail` Rust SDK to JavaScript and TypeScript consumers through `wasm-bindgen`. -The current MVP surface includes: +It is designed for browser and other `wasm32` environments that need: + +- read-only and signing audit-trail clients +- typed wrappers for trail handles, records, locking, access control, and tags +- serializable value and event types that map cleanly into JS/TS +- transaction wrappers that integrate with the shared `product_common` wasm transaction helpers + +## Main entry points + +- `AuditTrailClientReadOnly` for reads and inspected transactions +- `AuditTrailClient` for signed write flows +- `AuditTrailBuilder` for creating new trails +- `AuditTrailHandle` for trail-scoped APIs +- `TrailRecords`, `TrailLocking`, `TrailAccess`, and `TrailTags` for subsystem-specific operations + +## Choosing an entry point + +- Use `AuditTrailClientReadOnly` when you need reads, package resolution, or inspected transactions. +- Use `AuditTrailClient` when you also need typed write transaction builders. +- Use `AuditTrailHandle` after you already know the trail object ID and want to stay scoped to that trail. +- Use `AuditTrailBuilder` when you are preparing a create-trail transaction. + +## Data model wrappers + +The bindings expose JS-friendly wrappers for the most important Rust value types: -- `AuditTrailClientReadOnly` -- `AuditTrailClient` -- `AuditTrailBuilder` -- `AuditTrailHandle` -- `TrailRecords` - `Data` -- `Record` -- `PaginatedRecord` -- `OnChainAuditTrail` -- `ImmutableMetadata` -- `LockingConfig` -- `LockingWindow` -- `TimeLock` +- `Permission` and `PermissionSet` +- `RoleTags`, `RoleMap`, and `CapabilityIssueOptions` +- `TimeLock`, `LockingWindow`, and `LockingConfig` +- `Record`, `PaginatedRecord`, and `OnChainAuditTrail` +- event payloads such as `RecordAdded`, `RoleCreated`, and `CapabilityIssued` + +## Typical read flow + +1. Create an `AuditTrailClientReadOnly` or `AuditTrailClient`. +2. Resolve a trail handle with `.trail(trailId)`. +3. Read state with `.get()`, `.records().get(...)`, `.records().listPage(...)`, or `.locking().isRecordLocked(...)`. + +## Typical write flow + +1. Create an `AuditTrailClient` with a transaction signer. +2. Build a transaction from `client.createTrail()`, `client.trail(trailId)`, or one of the trail subsystem handles. +3. Convert that transaction wrapper into programmable transaction bytes. +4. Submit it through your surrounding JS transaction flow and feed the effects and events back into the typed `applyWithEvents(...)` helper. + +The bindings intentionally separate transaction construction from submission so browser apps, wallet integrations, and server-side signing flows can keep transport and execution policy outside the SDK. + +## Minimal TypeScript shape + +```ts +import { AuditTrailClientReadOnly } from "@iota/audit-trail-wasm"; + +const client = await AuditTrailClientReadOnly.create(iotaClient); +const trail = client.trail(trailId); +const state = await trail.get(); + +console.log(state.sequenceNumber); +``` ## Build @@ -27,4 +71,4 @@ npm run build ## Examples -See [examples/README.md](./examples/README.md) for the node example flows. +See [examples/README.md](./examples/README.md) for runnable node and web example flows. diff --git a/bindings/wasm/audit_trail_wasm/src/builder.rs b/bindings/wasm/audit_trail_wasm/src/builder.rs index c79b18f8..0db57672 100644 --- a/bindings/wasm/audit_trail_wasm/src/builder.rs +++ b/bindings/wasm/audit_trail_wasm/src/builder.rs @@ -11,16 +11,19 @@ use wasm_bindgen::prelude::*; use crate::trail::WasmCreateTrail; use crate::types::WasmLockingConfig; +/// Trail-creation builder exposed to wasm consumers. #[wasm_bindgen(js_name = AuditTrailBuilder, inspectable)] pub struct WasmAuditTrailBuilder(pub(crate) AuditTrailBuilder); #[wasm_bindgen(js_class = AuditTrailBuilder)] impl WasmAuditTrailBuilder { + /// Sets the initial record using a UTF-8 string payload. #[wasm_bindgen(js_name = withInitialRecordString)] pub fn with_initial_record_string(self, data: String, metadata: Option, tag: Option) -> Self { Self(self.0.with_initial_record_parts(data, metadata, tag)) } + /// Sets the initial record using raw bytes. #[wasm_bindgen(js_name = withInitialRecordBytes)] pub fn with_initial_record_bytes( self, @@ -31,32 +34,38 @@ impl WasmAuditTrailBuilder { Self(self.0.with_initial_record_parts(data.to_vec(), metadata, tag)) } + /// Sets immutable metadata for the trail. #[wasm_bindgen(js_name = withTrailMetadata)] pub fn with_trail_metadata(self, name: String, description: Option) -> Self { Self(self.0.with_trail_metadata_parts(name, description)) } + /// Sets mutable metadata for the trail. #[wasm_bindgen(js_name = withUpdatableMetadata)] pub fn with_updatable_metadata(self, metadata: String) -> Self { Self(self.0.with_updatable_metadata(metadata)) } + /// Sets the locking configuration for the trail. #[wasm_bindgen(js_name = withLockingConfig)] pub fn with_locking_config(self, config: WasmLockingConfig) -> Self { Self(self.0.with_locking_config(config.into())) } + /// Sets the canonical list of record tags owned by the trail. #[wasm_bindgen(js_name = withRecordTags)] pub fn with_record_tags(self, tags: Vec) -> Self { Self(self.0.with_record_tags(tags)) } + /// Sets the initial admin address. #[wasm_bindgen(js_name = withAdmin)] pub fn with_admin(self, admin: WasmIotaAddress) -> Result { let admin = parse_wasm_iota_address(&admin)?; Ok(Self(self.0.with_admin(admin))) } + /// Finalizes the builder into a transaction wrapper. #[wasm_bindgen(unchecked_return_type = "TransactionBuilder")] pub fn finish(self) -> Result { Ok(into_transaction_builder(WasmCreateTrail::new(self))) diff --git a/bindings/wasm/audit_trail_wasm/src/client.rs b/bindings/wasm/audit_trail_wasm/src/client.rs index a09a5ea1..fe65afb0 100644 --- a/bindings/wasm/audit_trail_wasm/src/client.rs +++ b/bindings/wasm/audit_trail_wasm/src/client.rs @@ -14,12 +14,17 @@ use crate::builder::WasmAuditTrailBuilder; use crate::client_read_only::{WasmAuditTrailClientReadOnly, WasmPackageOverrides}; use crate::trail_handle::WasmAuditTrailHandle; +/// Signing audit-trail client exposed to wasm consumers. +/// +/// This wraps the read-only client with a transaction signer so JS/TS consumers can build typed +/// write transactions while keeping submission and execution outside the SDK. #[derive(Clone)] #[wasm_bindgen(js_name = AuditTrailClient)] pub struct WasmAuditTrailClient(pub(crate) AuditTrailClient); #[wasm_bindgen(js_class = AuditTrailClient)] impl WasmAuditTrailClient { + /// Creates a signing client from an existing read-only client and signer. #[wasm_bindgen(js_name = create)] pub async fn new( client: WasmAuditTrailClientReadOnly, @@ -29,6 +34,10 @@ impl WasmAuditTrailClient { Ok(Self(client)) } + /// Creates a signing client directly from an IOTA client and signer. + /// + /// Pass `package_id` when connecting to a custom deployment that is not known to the package + /// registry. #[wasm_bindgen(js_name = createFromIotaClient)] pub async fn create_from_iota_client( iota_client: WasmIotaClient, @@ -54,6 +63,7 @@ impl WasmAuditTrailClient { Ok(Self(client)) } + /// Creates a signing client directly from an IOTA client, signer, and full package overrides. #[wasm_bindgen(js_name = createFromIotaClientWithPackageOverrides)] pub async fn create_from_iota_client_with_package_overrides( iota_client: WasmIotaClient, @@ -73,36 +83,43 @@ impl WasmAuditTrailClient { Ok(Self(client)) } + /// Returns the sender public key associated with the signer. #[wasm_bindgen(js_name = senderPublicKey)] pub fn sender_public_key(&self) -> Result { self.0.public_key().try_into() } + /// Returns the sender address associated with the signer. #[wasm_bindgen(js_name = senderAddress)] pub fn sender_address(&self) -> String { self.0.address().to_string() } + /// Returns the connected network name. #[wasm_bindgen] pub fn network(&self) -> String { self.0.network().to_string() } + /// Returns the connected chain ID. #[wasm_bindgen(js_name = chainId)] pub fn chain_id(&self) -> String { self.0.chain_id().to_string() } + /// Returns the audit-trail package ID used by this client. #[wasm_bindgen(js_name = packageId)] pub fn package_id(&self) -> String { self.0.package_id().to_string() } + /// Returns the `tf_components` package ID used by this client. #[wasm_bindgen(js_name = tfComponentsPackageId)] pub fn tf_components_package_id(&self) -> String { self.0.tf_components_package_id().to_string() } + /// Returns the resolved audit-trail package history as stringified object IDs. #[wasm_bindgen(js_name = packageHistory)] pub fn package_history(&self) -> Vec { self.0 @@ -112,16 +129,19 @@ impl WasmAuditTrailClient { .collect() } + /// Returns the underlying IOTA client wrapper. #[wasm_bindgen(js_name = iotaClient)] pub fn iota_client(&self) -> WasmIotaClient { self.0.read_only().iota_client().clone().into_inner() } + /// Returns the signer used by this client. #[wasm_bindgen] pub fn signer(&self) -> WasmTransactionSigner { self.0.signer().clone() } + /// Replaces the signer used by this client. #[wasm_bindgen(js_name = withSigner)] pub async fn with_signer(self, signer: WasmTransactionSigner) -> Result { let client = self @@ -132,16 +152,27 @@ impl WasmAuditTrailClient { Ok(Self(client)) } + /// Returns the read-only view of this client. + /// + /// This is useful when a caller wants to pass the client into code that only needs read + /// capabilities. #[wasm_bindgen(js_name = readOnly)] pub fn read_only(&self) -> WasmAuditTrailClientReadOnly { WasmAuditTrailClientReadOnly(self.0.read_only().clone()) } + /// Creates a builder for a new audit trail. + /// + /// The builder is pre-populated with the signer address as the initial admin when available. #[wasm_bindgen(js_name = createTrail)] pub fn create_trail(&self) -> WasmAuditTrailBuilder { WasmAuditTrailBuilder(self.0.create_trail()) } + /// Returns a trail-scoped handle for the given trail object ID. + /// + /// Creating the handle is cheap. Network reads and transaction building happen on the returned + /// handle and its subsystem wrappers. pub fn trail(&self, trail_id: WasmObjectID) -> Result { let trail_id = parse_wasm_object_id(&trail_id)?; Ok(WasmAuditTrailHandle::from_full(self.0.clone(), trail_id)) diff --git a/bindings/wasm/audit_trail_wasm/src/client_read_only.rs b/bindings/wasm/audit_trail_wasm/src/client_read_only.rs index aaa8a716..042d72e2 100644 --- a/bindings/wasm/audit_trail_wasm/src/client_read_only.rs +++ b/bindings/wasm/audit_trail_wasm/src/client_read_only.rs @@ -11,17 +11,21 @@ use wasm_bindgen::prelude::*; use crate::trail_handle::WasmAuditTrailHandle; +/// Package-ID overrides exposed to JavaScript and TypeScript consumers. #[derive(Clone)] #[wasm_bindgen(js_name = PackageOverrides, getter_with_clone, inspectable)] pub struct WasmPackageOverrides { + /// Override for the audit-trail package ID. #[wasm_bindgen(js_name = auditTrailPackageId)] pub audit_trail_package_id: Option, + /// Override for the `tf_components` package ID. #[wasm_bindgen(js_name = tfComponentsPackageId)] pub tf_components_package_id: Option, } #[wasm_bindgen(js_class = PackageOverrides)] impl WasmPackageOverrides { + /// Creates package overrides for custom deployments. #[wasm_bindgen(constructor)] pub fn new( audit_trail_package_id: Option, @@ -53,18 +57,31 @@ impl TryFrom for PackageOverrides { } } +/// Read-only audit-trail client exposed to wasm consumers. +/// +/// This is the main JS/TS entry point for package resolution and typed reads. Use [`Self::trail`] +/// to get an [`AuditTrailHandle`](crate::trail_handle::WasmAuditTrailHandle) bound to one trail +/// object. #[derive(Clone)] #[wasm_bindgen(js_name = AuditTrailClientReadOnly)] pub struct WasmAuditTrailClientReadOnly(pub(crate) AuditTrailClientReadOnly); #[wasm_bindgen(js_class = AuditTrailClientReadOnly)] impl WasmAuditTrailClientReadOnly { + /// Creates a read-only client by resolving package IDs from the connected network. + /// + /// This is the recommended constructor for official deployments tracked by the built-in + /// package registry. #[wasm_bindgen(js_name = create)] pub async fn new(iota_client: WasmIotaClient) -> Result { let client = AuditTrailClientReadOnly::new(iota_client).await.wasm_result()?; Ok(Self(client)) } + /// Creates a read-only client with explicit package overrides. + /// + /// Prefer this when your JS/TS app talks to a local deployment, preview environment, or any + /// package pair that is not yet part of the registry baked into the SDK. #[wasm_bindgen(js_name = createWithPackageOverrides)] pub async fn new_with_package_overrides( iota_client: WasmIotaClient, @@ -77,6 +94,10 @@ impl WasmAuditTrailClientReadOnly { Ok(Self(client)) } + /// Creates a read-only client while overriding only the audit-trail package ID. + /// + /// This is a compatibility helper for existing callers that only need a single package + /// override. #[wasm_bindgen(js_name = createWithPkgId)] pub async fn new_with_pkg_id( iota_client: WasmIotaClient, @@ -95,16 +116,19 @@ impl WasmAuditTrailClientReadOnly { Ok(Self(client)) } + /// Returns the audit-trail package ID used by this client. #[wasm_bindgen(js_name = packageId)] pub fn package_id(&self) -> String { self.0.package_id().to_string() } + /// Returns the `tf_components` package ID used by this client. #[wasm_bindgen(js_name = tfComponentsPackageId)] pub fn tf_components_package_id(&self) -> String { self.0.tf_components_package_id().to_string() } + /// Returns the resolved audit-trail package history as stringified object IDs. #[wasm_bindgen(js_name = packageHistory)] pub fn package_history(&self) -> Vec { self.0 @@ -114,21 +138,28 @@ impl WasmAuditTrailClientReadOnly { .collect() } + /// Returns the connected network name. #[wasm_bindgen] pub fn network(&self) -> String { self.0.network().to_string() } + /// Returns the connected chain ID. #[wasm_bindgen(js_name = chainId)] pub fn chain_id(&self) -> String { self.0.chain_id().to_string() } + /// Returns the underlying IOTA client wrapper. #[wasm_bindgen(js_name = iotaClient)] pub fn iota_client(&self) -> WasmIotaClient { self.0.iota_client().clone().into_inner() } + /// Returns a trail-scoped handle for the given trail object ID. + /// + /// Creating the handle is cheap. Reads only happen when you call methods on the returned + /// handle. pub fn trail(&self, trail_id: WasmObjectID) -> Result { let trail_id = parse_wasm_object_id(&trail_id)?; Ok(WasmAuditTrailHandle::from_read_only(self.0.clone(), trail_id)) diff --git a/bindings/wasm/audit_trail_wasm/src/lib.rs b/bindings/wasm/audit_trail_wasm/src/lib.rs index fa475db9..8e19f317 100644 --- a/bindings/wasm/audit_trail_wasm/src/lib.rs +++ b/bindings/wasm/audit_trail_wasm/src/lib.rs @@ -1,6 +1,8 @@ // Copyright 2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +#![doc = include_str!("../README.md")] +#![warn(rustdoc::all)] #![allow(deprecated)] #![allow(clippy::upper_case_acronyms)] #![allow(clippy::drop_non_drop)] @@ -17,8 +19,10 @@ mod trail; pub(crate) mod trail_handle; pub(crate) mod types; +/// Shared wasm bindings re-exported from `product_common`. pub use product_common::bindings::*; +/// Installs the panic hook used by the wasm bindings. #[wasm_bindgen(start)] pub fn start() -> std::result::Result<(), JsValue> { console_error_panic_hook::set_once(); diff --git a/bindings/wasm/audit_trail_wasm/src/trail.rs b/bindings/wasm/audit_trail_wasm/src/trail.rs index 3b63d874..dc9c995d 100644 --- a/bindings/wasm/audit_trail_wasm/src/trail.rs +++ b/bindings/wasm/audit_trail_wasm/src/trail.rs @@ -30,6 +30,7 @@ use crate::types::{ WasmRoleCreated, WasmRoleDeleted, WasmRoleMap, WasmRoleUpdated, }; +/// Read-only view of an on-chain audit trail for wasm consumers. #[wasm_bindgen(js_name = OnChainAuditTrail, inspectable)] #[derive(Clone)] pub struct WasmOnChainAuditTrail(pub(crate) OnChainAuditTrail); @@ -40,36 +41,43 @@ impl WasmOnChainAuditTrail { Self(trail) } + /// Returns the trail object ID. #[wasm_bindgen(getter)] pub fn id(&self) -> String { self.0.id.id.to_string() } + /// Returns the creator address. #[wasm_bindgen(getter)] pub fn creator(&self) -> String { self.0.creator.to_string() } + /// Returns the creation timestamp in milliseconds. #[wasm_bindgen(js_name = createdAt, getter)] pub fn created_at(&self) -> u64 { self.0.created_at } + /// Returns the current record sequence counter. #[wasm_bindgen(js_name = sequenceNumber, getter)] pub fn sequence_number(&self) -> u64 { self.0.sequence_number } + /// Returns the active locking configuration. #[wasm_bindgen(js_name = lockingConfig, getter)] pub fn locking_config(&self) -> WasmLockingConfig { self.0.locking_config.clone().into() } + /// Returns the record linked-table metadata. #[wasm_bindgen(getter)] pub fn records(&self) -> WasmLinkedTable { self.0.records.clone().into() } + /// Returns the trail-owned record tags together with usage counts. #[wasm_bindgen(getter)] pub fn tags(&self) -> Vec { let mut tags: Vec = self @@ -82,21 +90,25 @@ impl WasmOnChainAuditTrail { tags } + /// Returns the trail role map. #[wasm_bindgen(getter)] pub fn roles(&self) -> WasmRoleMap { self.0.roles.clone().into() } + /// Returns immutable metadata when present. #[wasm_bindgen(js_name = immutableMetadata, getter)] pub fn immutable_metadata(&self) -> Option { self.0.immutable_metadata.clone().map(Into::into) } + /// Returns mutable metadata when present. #[wasm_bindgen(js_name = updatableMetadata, getter)] pub fn updatable_metadata(&self) -> Option { self.0.updatable_metadata.clone() } + /// Returns the on-chain version of the trail object. #[wasm_bindgen(getter)] pub fn version(&self) -> u64 { self.0.version @@ -121,21 +133,25 @@ async fn apply_trail_created( Ok(trail.into()) } +/// Transaction wrapper for trail creation. #[wasm_bindgen(js_name = CreateTrail, inspectable)] pub struct WasmCreateTrail(pub(crate) CreateTrail); #[wasm_bindgen(js_class = CreateTrail)] impl WasmCreateTrail { + /// Creates a transaction wrapper from an [`AuditTrailBuilder`](crate::builder::WasmAuditTrailBuilder). #[wasm_bindgen(constructor)] pub fn new(builder: WasmAuditTrailBuilder) -> Self { Self(CreateTrail::new(builder.0)) } + /// Builds the programmable transaction bytes for submission. #[wasm_bindgen(js_name = buildProgrammableTransaction)] pub async fn build_programmable_transaction(&self, client: &WasmCoreClientReadOnly) -> Result> { build_programmable_transaction(&self.0, client).await } + /// Applies transaction effects and events and then fetches the created trail object. #[wasm_bindgen(js_name = applyWithEvents)] pub async fn apply_with_events( self, @@ -147,6 +163,7 @@ impl WasmCreateTrail { } } +/// Transaction wrapper for mutable-metadata updates. #[wasm_bindgen(js_name = UpdateMetadata, inspectable)] pub struct WasmUpdateMetadata(pub(crate) UpdateMetadata); @@ -168,6 +185,7 @@ impl WasmUpdateMetadata { } } +/// Transaction wrapper for trail migration. #[wasm_bindgen(js_name = Migrate, inspectable)] pub struct WasmMigrate(pub(crate) Migrate); @@ -189,6 +207,7 @@ impl WasmMigrate { } } +/// Transaction wrapper for deleting a trail. #[wasm_bindgen(js_name = DeleteAuditTrail, inspectable)] pub struct WasmDeleteAuditTrail(pub(crate) DeleteAuditTrail); @@ -211,6 +230,7 @@ impl WasmDeleteAuditTrail { } } +/// Transaction wrapper for replacing the full locking configuration. #[wasm_bindgen(js_name = UpdateLockingConfig, inspectable)] pub struct WasmUpdateLockingConfig(pub(crate) UpdateLockingConfig); @@ -232,6 +252,7 @@ impl WasmUpdateLockingConfig { } } +/// Transaction wrapper for updating the delete-record window. #[wasm_bindgen(js_name = UpdateDeleteRecordWindow, inspectable)] pub struct WasmUpdateDeleteRecordWindow(pub(crate) UpdateDeleteRecordWindow); @@ -253,6 +274,7 @@ impl WasmUpdateDeleteRecordWindow { } } +/// Transaction wrapper for updating the delete-trail lock. #[wasm_bindgen(js_name = UpdateDeleteTrailLock, inspectable)] pub struct WasmUpdateDeleteTrailLock(pub(crate) UpdateDeleteTrailLock); @@ -274,6 +296,7 @@ impl WasmUpdateDeleteTrailLock { } } +/// Transaction wrapper for updating the write lock. #[wasm_bindgen(js_name = UpdateWriteLock, inspectable)] pub struct WasmUpdateWriteLock(pub(crate) UpdateWriteLock); @@ -295,6 +318,7 @@ impl WasmUpdateWriteLock { } } +/// Transaction wrapper for creating a role. #[wasm_bindgen(js_name = CreateRole, inspectable)] pub struct WasmCreateRole(pub(crate) CreateRole); @@ -317,6 +341,7 @@ impl WasmCreateRole { } } +/// Transaction wrapper for updating a role. #[wasm_bindgen(js_name = UpdateRole, inspectable)] pub struct WasmUpdateRole(pub(crate) UpdateRole); @@ -339,6 +364,7 @@ impl WasmUpdateRole { } } +/// Transaction wrapper for deleting a role. #[wasm_bindgen(js_name = DeleteRole, inspectable)] pub struct WasmDeleteRole(pub(crate) DeleteRole); @@ -361,6 +387,7 @@ impl WasmDeleteRole { } } +/// Transaction wrapper for issuing a capability. #[wasm_bindgen(js_name = IssueCapability, inspectable)] pub struct WasmIssueCapability(pub(crate) IssueCapability); @@ -383,6 +410,7 @@ impl WasmIssueCapability { } } +/// Transaction wrapper for revoking a capability. #[wasm_bindgen(js_name = RevokeCapability, inspectable)] pub struct WasmRevokeCapability(pub(crate) RevokeCapability); @@ -405,6 +433,7 @@ impl WasmRevokeCapability { } } +/// Transaction wrapper for destroying a capability. #[wasm_bindgen(js_name = DestroyCapability, inspectable)] pub struct WasmDestroyCapability(pub(crate) DestroyCapability); @@ -427,6 +456,7 @@ impl WasmDestroyCapability { } } +/// Transaction wrapper for destroying an initial-admin capability. #[wasm_bindgen(js_name = DestroyInitialAdminCapability, inspectable)] pub struct WasmDestroyInitialAdminCapability(pub(crate) DestroyInitialAdminCapability); @@ -449,6 +479,7 @@ impl WasmDestroyInitialAdminCapability { } } +/// Transaction wrapper for revoking an initial-admin capability. #[wasm_bindgen(js_name = RevokeInitialAdminCapability, inspectable)] pub struct WasmRevokeInitialAdminCapability(pub(crate) RevokeInitialAdminCapability); @@ -471,6 +502,7 @@ impl WasmRevokeInitialAdminCapability { } } +/// Transaction wrapper for cleaning up expired revoked-capability entries. #[wasm_bindgen(js_name = CleanupRevokedCapabilities, inspectable)] pub struct WasmCleanupRevokedCapabilities(pub(crate) CleanupRevokedCapabilities); @@ -492,6 +524,7 @@ impl WasmCleanupRevokedCapabilities { } } +/// Transaction wrapper for adding a record. #[wasm_bindgen(js_name = AddRecord, inspectable)] pub struct WasmAddRecord(pub(crate) AddRecord); @@ -514,6 +547,7 @@ impl WasmAddRecord { } } +/// Transaction wrapper for deleting a single record. #[wasm_bindgen(js_name = DeleteRecord, inspectable)] pub struct WasmDeleteRecord(pub(crate) DeleteRecord); @@ -536,6 +570,7 @@ impl WasmDeleteRecord { } } +/// Transaction wrapper for deleting records in batch form. #[wasm_bindgen(js_name = DeleteRecordsBatch, inspectable)] pub struct WasmDeleteRecordsBatch(pub(crate) DeleteRecordsBatch); @@ -557,6 +592,7 @@ impl WasmDeleteRecordsBatch { } } +/// Transaction wrapper for adding a record tag to the trail registry. #[wasm_bindgen(js_name = AddRecordTag, inspectable)] pub struct WasmAddRecordTag(pub(crate) AddRecordTag); @@ -578,6 +614,7 @@ impl WasmAddRecordTag { } } +/// Transaction wrapper for removing a record tag from the trail registry. #[wasm_bindgen(js_name = RemoveRecordTag, inspectable)] pub struct WasmRemoveRecordTag(pub(crate) RemoveRecordTag); diff --git a/bindings/wasm/audit_trail_wasm/src/trail_handle/access.rs b/bindings/wasm/audit_trail_wasm/src/trail_handle/access.rs index 6b5d1862..76e1ce2b 100644 --- a/bindings/wasm/audit_trail_wasm/src/trail_handle/access.rs +++ b/bindings/wasm/audit_trail_wasm/src/trail_handle/access.rs @@ -18,6 +18,7 @@ use crate::trail::{ }; use crate::types::{WasmCapabilityIssueOptions, WasmPermissionSet, WasmRoleTags}; +/// Access-control API scoped to a specific trail. #[derive(Clone)] #[wasm_bindgen(js_name = TrailAccess, inspectable)] pub struct WasmTrailAccess { @@ -40,6 +41,7 @@ impl WasmTrailAccess { #[wasm_bindgen(js_class = TrailAccess)] impl WasmTrailAccess { + /// Returns a role-scoped handle for the given role name. #[wasm_bindgen(js_name = forRole)] pub fn for_role(&self, name: String) -> WasmRoleHandle { WasmRoleHandle { @@ -49,6 +51,7 @@ impl WasmTrailAccess { } } + /// Builds a capability-revocation transaction. #[wasm_bindgen(js_name = revokeCapability, unchecked_return_type = "TransactionBuilder")] pub fn revoke_capability( &self, @@ -65,6 +68,7 @@ impl WasmTrailAccess { Ok(into_transaction_builder(WasmRevokeCapability(tx))) } + /// Builds a capability-destruction transaction. #[wasm_bindgen(js_name = destroyCapability, unchecked_return_type = "TransactionBuilder")] pub fn destroy_capability(&self, capability_id: WasmObjectID) -> Result { let capability_id = parse_wasm_object_id(&capability_id)?; @@ -77,6 +81,7 @@ impl WasmTrailAccess { Ok(into_transaction_builder(WasmDestroyCapability(tx))) } + /// Builds an initial-admin-capability destruction transaction. #[wasm_bindgen(js_name = destroyInitialAdminCapability, unchecked_return_type = "TransactionBuilder")] pub fn destroy_initial_admin_capability(&self, capability_id: WasmObjectID) -> Result { let capability_id = parse_wasm_object_id(&capability_id)?; @@ -89,6 +94,7 @@ impl WasmTrailAccess { Ok(into_transaction_builder(WasmDestroyInitialAdminCapability(tx))) } + /// Builds an initial-admin-capability revocation transaction. #[wasm_bindgen(js_name = revokeInitialAdminCapability, unchecked_return_type = "TransactionBuilder")] pub fn revoke_initial_admin_capability( &self, @@ -105,6 +111,7 @@ impl WasmTrailAccess { Ok(into_transaction_builder(WasmRevokeInitialAdminCapability(tx))) } + /// Builds a cleanup transaction for expired revoked-capability entries. #[wasm_bindgen(js_name = cleanupRevokedCapabilities, unchecked_return_type = "TransactionBuilder")] pub fn cleanup_revoked_capabilities(&self) -> Result { let tx = self @@ -117,6 +124,7 @@ impl WasmTrailAccess { } } +/// Role-scoped access-control API. #[derive(Clone)] #[wasm_bindgen(js_name = RoleHandle, inspectable)] pub struct WasmRoleHandle { @@ -140,11 +148,13 @@ impl WasmRoleHandle { #[wasm_bindgen(js_class = RoleHandle)] impl WasmRoleHandle { + /// Returns the role name represented by this handle. #[wasm_bindgen(getter)] pub fn name(&self) -> String { self.name.clone() } + /// Builds a role-creation transaction. #[wasm_bindgen(unchecked_return_type = "TransactionBuilder")] pub fn create( &self, @@ -161,6 +171,7 @@ impl WasmRoleHandle { Ok(into_transaction_builder(WasmCreateRole(tx))) } + /// Builds a capability-issuance transaction for this role. #[wasm_bindgen(js_name = issueCapability, unchecked_return_type = "TransactionBuilder")] pub fn issue_capability(&self, options: WasmCapabilityIssueOptions) -> Result { let tx = self @@ -173,6 +184,7 @@ impl WasmRoleHandle { Ok(into_transaction_builder(WasmIssueCapability(tx))) } + /// Builds a role-update transaction for this role. #[wasm_bindgen(js_name = updatePermissions, unchecked_return_type = "TransactionBuilder")] pub fn update_permissions( &self, @@ -189,6 +201,7 @@ impl WasmRoleHandle { Ok(into_transaction_builder(WasmUpdateRole(tx))) } + /// Builds a role-deletion transaction for this role. #[wasm_bindgen(unchecked_return_type = "TransactionBuilder")] pub fn delete(&self) -> Result { let tx = self diff --git a/bindings/wasm/audit_trail_wasm/src/trail_handle/locking.rs b/bindings/wasm/audit_trail_wasm/src/trail_handle/locking.rs index 0ed6fb9e..6c56d997 100644 --- a/bindings/wasm/audit_trail_wasm/src/trail_handle/locking.rs +++ b/bindings/wasm/audit_trail_wasm/src/trail_handle/locking.rs @@ -15,6 +15,7 @@ use crate::trail::{ }; use crate::types::{WasmLockingConfig, WasmLockingWindow, WasmTimeLock}; +/// Locking API scoped to a specific trail. #[derive(Clone)] #[wasm_bindgen(js_name = TrailLocking, inspectable)] pub struct WasmTrailLocking { @@ -38,6 +39,7 @@ impl WasmTrailLocking { #[wasm_bindgen(js_class = TrailLocking)] impl WasmTrailLocking { + /// Builds a transaction that replaces the full locking configuration. #[wasm_bindgen(unchecked_return_type = "TransactionBuilder")] pub fn update(&self, config: WasmLockingConfig) -> Result { let tx = self @@ -49,6 +51,7 @@ impl WasmTrailLocking { Ok(into_transaction_builder(WasmUpdateLockingConfig(tx))) } + /// Builds a transaction that updates only the delete-record window. #[wasm_bindgen(js_name = updateDeleteRecordWindow, unchecked_return_type = "TransactionBuilder")] pub fn update_delete_record_window(&self, window: WasmLockingWindow) -> Result { let tx = self @@ -60,6 +63,7 @@ impl WasmTrailLocking { Ok(into_transaction_builder(WasmUpdateDeleteRecordWindow(tx))) } + /// Builds a transaction that updates only the delete-trail lock. #[wasm_bindgen(js_name = updateDeleteTrailLock, unchecked_return_type = "TransactionBuilder")] pub fn update_delete_trail_lock(&self, lock: WasmTimeLock) -> Result { let tx = self @@ -71,6 +75,7 @@ impl WasmTrailLocking { Ok(into_transaction_builder(WasmUpdateDeleteTrailLock(tx))) } + /// Builds a transaction that updates only the write lock. #[wasm_bindgen(js_name = updateWriteLock, unchecked_return_type = "TransactionBuilder")] pub fn update_write_lock(&self, lock: WasmTimeLock) -> Result { let tx = self @@ -82,6 +87,7 @@ impl WasmTrailLocking { Ok(into_transaction_builder(WasmUpdateWriteLock(tx))) } + /// Returns whether a record is currently locked against deletion. #[wasm_bindgen(js_name = isRecordLocked)] pub async fn is_record_locked(&self, sequence_number: u64) -> Result { self.read_only diff --git a/bindings/wasm/audit_trail_wasm/src/trail_handle/mod.rs b/bindings/wasm/audit_trail_wasm/src/trail_handle/mod.rs index 2711433b..a2587f31 100644 --- a/bindings/wasm/audit_trail_wasm/src/trail_handle/mod.rs +++ b/bindings/wasm/audit_trail_wasm/src/trail_handle/mod.rs @@ -1,6 +1,8 @@ // Copyright 2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +//! Trail-scoped wasm handle wrappers. + mod access; mod locking; mod records; @@ -21,6 +23,10 @@ use wasm_bindgen::prelude::*; use crate::trail::{WasmDeleteAuditTrail, WasmMigrate, WasmOnChainAuditTrail, WasmUpdateMetadata}; +/// Handle bound to a specific audit-trail object. +/// +/// `AuditTrailHandle` keeps one trail ID together with the originating client so all trail-scoped +/// reads and transaction builders can be discovered from a single JS/TS value. #[derive(Clone)] #[wasm_bindgen(js_name = AuditTrailHandle, inspectable)] pub struct WasmAuditTrailHandle { @@ -60,17 +66,22 @@ impl WasmAuditTrailHandle { #[wasm_bindgen(js_class = AuditTrailHandle)] impl WasmAuditTrailHandle { + /// Loads the full on-chain trail object. + /// + /// Each call fetches a fresh snapshot from chain state. pub async fn get(&self) -> Result { let trail = self.read_only.trail(self.trail_id).get().await.wasm_result()?; Ok(trail.into()) } + /// Builds a migration transaction for this trail. #[wasm_bindgen(unchecked_return_type = "TransactionBuilder")] pub fn migrate(&self) -> Result { let tx = self.require_write()?.trail(self.trail_id).migrate().into_inner(); Ok(into_transaction_builder(WasmMigrate(tx))) } + /// Builds a delete transaction for this trail. #[wasm_bindgen(js_name = deleteAuditTrail, unchecked_return_type = "TransactionBuilder")] pub fn delete_audit_trail(&self) -> Result { let tx = self @@ -81,6 +92,7 @@ impl WasmAuditTrailHandle { Ok(into_transaction_builder(WasmDeleteAuditTrail(tx))) } + /// Builds a mutable-metadata update transaction for this trail. #[wasm_bindgen(js_name = updateMetadata, unchecked_return_type = "TransactionBuilder")] pub fn update_metadata(&self, metadata: Option) -> Result { let tx = self @@ -91,6 +103,7 @@ impl WasmAuditTrailHandle { Ok(into_transaction_builder(WasmUpdateMetadata(tx))) } + /// Returns the record API scoped to this trail. pub fn records(&self) -> WasmTrailRecords { WasmTrailRecords { read_only: self.read_only.clone(), @@ -99,6 +112,7 @@ impl WasmAuditTrailHandle { } } + /// Returns the access-control API scoped to this trail. pub fn access(&self) -> WasmTrailAccess { WasmTrailAccess { full: self.full.clone(), @@ -106,6 +120,7 @@ impl WasmAuditTrailHandle { } } + /// Returns the locking API scoped to this trail. pub fn locking(&self) -> WasmTrailLocking { WasmTrailLocking { read_only: self.read_only.clone(), @@ -114,6 +129,7 @@ impl WasmAuditTrailHandle { } } + /// Returns the tag-registry API scoped to this trail. pub fn tags(&self) -> WasmTrailTags { WasmTrailTags { full: self.full.clone(), diff --git a/bindings/wasm/audit_trail_wasm/src/trail_handle/records.rs b/bindings/wasm/audit_trail_wasm/src/trail_handle/records.rs index 68456e5e..d4b646b2 100644 --- a/bindings/wasm/audit_trail_wasm/src/trail_handle/records.rs +++ b/bindings/wasm/audit_trail_wasm/src/trail_handle/records.rs @@ -14,6 +14,7 @@ use wasm_bindgen::prelude::*; use crate::trail::{WasmAddRecord, WasmDeleteRecord, WasmDeleteRecordsBatch}; use crate::types::{WasmData, WasmEmpty, WasmPaginatedRecord, WasmRecord}; +/// Record API scoped to a specific trail. #[derive(Clone)] #[wasm_bindgen(js_name = TrailRecords, inspectable)] pub struct WasmTrailRecords { @@ -37,6 +38,7 @@ impl WasmTrailRecords { #[wasm_bindgen(js_class = TrailRecords)] impl WasmTrailRecords { + /// Loads one record by sequence number. pub async fn get(&self, sequence_number: u64) -> Result { let record = self .read_only @@ -48,6 +50,7 @@ impl WasmTrailRecords { Ok(record.into()) } + /// Returns the number of records currently stored in the trail. #[wasm_bindgen(js_name = recordCount)] pub async fn record_count(&self) -> Result { self.read_only @@ -58,6 +61,7 @@ impl WasmTrailRecords { .wasm_result() } + /// Lists all records in sequence-number order. pub async fn list(&self) -> Result> { let mut records: Vec<_> = self .read_only @@ -72,6 +76,7 @@ impl WasmTrailRecords { Ok(records.into_iter().map(|(_, record)| record.into()).collect()) } + /// Lists all records while enforcing a maximum number of entries. #[wasm_bindgen(js_name = listWithLimit)] pub async fn list_with_limit(&self, max_entries: usize) -> Result> { let mut records: Vec<_> = self @@ -87,6 +92,7 @@ impl WasmTrailRecords { Ok(records.into_iter().map(|(_, record)| record.into()).collect()) } + /// Loads one page of records starting at `cursor`. #[wasm_bindgen(js_name = listPage)] pub async fn list_page(&self, cursor: Option, limit: usize) -> Result { let page = self @@ -99,6 +105,7 @@ impl WasmTrailRecords { Ok(page.into()) } + /// Executes the correction helper for a record payload. pub async fn correct(&self, replaces: Vec, data: WasmData, metadata: Option) -> Result { self.require_write()? .trail(self.trail_id) @@ -109,6 +116,7 @@ impl WasmTrailRecords { Ok(WasmEmpty) } + /// Builds a record-add transaction. #[wasm_bindgen(unchecked_return_type = "TransactionBuilder")] pub fn add(&self, data: WasmData, metadata: Option, tag: Option) -> Result { let tx = self @@ -120,6 +128,7 @@ impl WasmTrailRecords { Ok(into_transaction_builder(WasmAddRecord(tx))) } + /// Builds a single-record delete transaction. #[wasm_bindgen(unchecked_return_type = "TransactionBuilder")] pub fn delete(&self, sequence_number: u64) -> Result { let tx = self @@ -131,6 +140,7 @@ impl WasmTrailRecords { Ok(into_transaction_builder(WasmDeleteRecord(tx))) } + /// Builds a batched record-delete transaction. #[wasm_bindgen(js_name = deleteBatch, unchecked_return_type = "TransactionBuilder")] pub fn delete_batch(&self, limit: u64) -> Result { let tx = self diff --git a/bindings/wasm/audit_trail_wasm/src/trail_handle/tags.rs b/bindings/wasm/audit_trail_wasm/src/trail_handle/tags.rs index 9e0e3766..24a326ef 100644 --- a/bindings/wasm/audit_trail_wasm/src/trail_handle/tags.rs +++ b/bindings/wasm/audit_trail_wasm/src/trail_handle/tags.rs @@ -12,6 +12,7 @@ use wasm_bindgen::prelude::*; use crate::trail::{WasmAddRecordTag, WasmRemoveRecordTag}; +/// Tag-registry API scoped to a specific trail. #[derive(Clone)] #[wasm_bindgen(js_name = TrailTags, inspectable)] pub struct WasmTrailTags { @@ -20,6 +21,7 @@ pub struct WasmTrailTags { } impl WasmTrailTags { + /// Returns the writable client for tag mutations. fn require_write(&self) -> Result<&AuditTrailClient> { self.full.as_ref().ok_or_else(|| { wasm_error(anyhow!( @@ -31,12 +33,14 @@ impl WasmTrailTags { #[wasm_bindgen(js_class = TrailTags)] impl WasmTrailTags { + /// Builds a transaction that adds a tag to the trail registry. #[wasm_bindgen(unchecked_return_type = "TransactionBuilder")] pub fn add(&self, tag: String) -> Result { let tx = self.require_write()?.trail(self.trail_id).tags().add(tag).into_inner(); Ok(into_transaction_builder(WasmAddRecordTag(tx))) } + /// Builds a transaction that removes a tag from the trail registry. #[wasm_bindgen(unchecked_return_type = "TransactionBuilder")] pub fn remove(&self, tag: String) -> Result { let tx = self diff --git a/bindings/wasm/audit_trail_wasm/src/types.rs b/bindings/wasm/audit_trail_wasm/src/types.rs index da65fd76..52cc9586 100644 --- a/bindings/wasm/audit_trail_wasm/src/types.rs +++ b/bindings/wasm/audit_trail_wasm/src/types.rs @@ -16,6 +16,7 @@ use product_common::bindings::WasmIotaAddress; use serde::{Deserialize, Serialize}; use wasm_bindgen::prelude::*; +/// Placeholder wrapper used for transaction outputs that carry no value. #[wasm_bindgen(js_name = Empty, inspectable)] pub struct WasmEmpty; @@ -25,12 +26,14 @@ impl From<()> for WasmEmpty { } } +/// JS-friendly wrapper for audit-trail record payloads. #[wasm_bindgen(js_name = Data, inspectable)] #[derive(Clone)] pub struct WasmData(pub(crate) Data); #[wasm_bindgen(js_class = Data)] impl WasmData { + /// Returns the underlying payload as either a string or `Uint8Array`. #[wasm_bindgen(getter)] pub fn value(&self) -> JsValue { match &self.0 { @@ -39,6 +42,7 @@ impl WasmData { } } + /// Returns the payload converted to a string. #[wasm_bindgen(js_name = toString)] pub fn to_string(&self) -> String { match &self.0 { @@ -47,6 +51,7 @@ impl WasmData { } } + /// Returns the payload converted to raw bytes. #[wasm_bindgen(js_name = toBytes)] pub fn to_bytes(&self) -> Vec { match &self.0 { @@ -55,11 +60,13 @@ impl WasmData { } } + /// Creates a text payload. #[wasm_bindgen(js_name = fromString)] pub fn from_string(data: String) -> Self { Self(Data::text(data)) } + /// Creates a binary payload. #[wasm_bindgen(js_name = fromBytes)] pub fn from_bytes(data: Uint8Array) -> Self { Self(Data::bytes(data.to_vec())) @@ -137,6 +144,7 @@ fn sorted_role_entries(roles: HashMap) -> Vec for Permission { } } +/// JS-friendly wrapper for a set of permissions. #[wasm_bindgen(js_name = PermissionSet, getter_with_clone, inspectable)] #[derive(Clone)] pub struct WasmPermissionSet { + /// Permissions granted by this set. pub permissions: Vec, } #[wasm_bindgen(js_class = PermissionSet)] impl WasmPermissionSet { + /// Creates a permission set from an explicit list of permissions. #[wasm_bindgen(constructor)] pub fn new(permissions: Vec) -> Self { Self { permissions } } + /// Returns the recommended role-administration permission set. #[wasm_bindgen(js_name = adminPermissions)] pub fn admin_permissions() -> Self { PermissionSet::admin_permissions().into() } + /// Returns the permissions needed to administer records. #[wasm_bindgen(js_name = recordAdminPermissions)] pub fn record_admin_permissions() -> Self { PermissionSet::record_admin_permissions().into() } + /// Returns the permissions needed to administer locking rules. #[wasm_bindgen(js_name = lockingAdminPermissions)] pub fn locking_admin_permissions() -> Self { PermissionSet::locking_admin_permissions().into() } + /// Returns the permissions needed to administer roles. #[wasm_bindgen(js_name = roleAdminPermissions)] pub fn role_admin_permissions() -> Self { PermissionSet::role_admin_permissions().into() } + /// Returns the permissions needed to issue and revoke capabilities. #[wasm_bindgen(js_name = capAdminPermissions)] pub fn cap_admin_permissions() -> Self { PermissionSet::cap_admin_permissions().into() } + /// Returns the permissions needed to administer mutable metadata. #[wasm_bindgen(js_name = metadataAdminPermissions)] pub fn metadata_admin_permissions() -> Self { PermissionSet::metadata_admin_permissions().into() } + /// Returns the permissions needed to administer record tags. #[wasm_bindgen(js_name = tagAdminPermissions)] pub fn tag_admin_permissions() -> Self { PermissionSet::tag_admin_permissions().into() @@ -278,12 +296,17 @@ impl From for PermissionSet { } } +/// Linked-table metadata for record storage. #[wasm_bindgen(js_name = LinkedTable, getter_with_clone, inspectable)] #[derive(Clone, Serialize, Deserialize)] pub struct WasmLinkedTable { + /// Linked-table object ID. pub id: String, + /// Declared number of entries in the table. pub size: u64, + /// Sequence number of the first entry, if any. pub head: Option, + /// Sequence number of the last entry, if any. pub tail: Option, } @@ -298,6 +321,7 @@ impl From> for WasmLinkedTable { } } +/// Permission requirements for role administration. #[wasm_bindgen(js_name = RoleAdminPermissions, getter_with_clone, inspectable)] #[derive(Clone, Serialize, Deserialize)] pub struct WasmRoleAdminPermissions { @@ -316,6 +340,7 @@ impl From for WasmRoleAdminPermissions { } } +/// Permission requirements for capability administration. #[wasm_bindgen(js_name = CapabilityAdminPermissions, getter_with_clone, inspectable)] #[derive(Clone, Serialize, Deserialize)] pub struct WasmCapabilityAdminPermissions { @@ -332,6 +357,7 @@ impl From for WasmCapabilityAdminPermissions { } } +/// Flattened role entry exposed inside [`WasmRoleMap`]. #[wasm_bindgen(js_name = RolePermissionsEntry, getter_with_clone, inspectable)] #[derive(Clone)] pub struct WasmRolePermissionsEntry { @@ -341,14 +367,17 @@ pub struct WasmRolePermissionsEntry { pub role_tags: Option, } +/// Allowlisted record tags stored on a role. #[wasm_bindgen(js_name = RoleTags, getter_with_clone, inspectable)] #[derive(Clone, Serialize, Deserialize)] pub struct WasmRoleTags { + /// Sorted tag names allowed by the role. pub tags: Vec, } #[wasm_bindgen(js_class = RoleTags)] impl WasmRoleTags { + /// Creates role-tag restrictions from a list of tag names. #[wasm_bindgen(constructor)] pub fn new(tags: Vec) -> Self { let mut tags = tags; @@ -372,6 +401,7 @@ impl From for RoleTags { } } +/// Trail-owned record tag plus its usage count. #[wasm_bindgen(js_name = RecordTagEntry, getter_with_clone, inspectable)] #[derive(Clone, Serialize, Deserialize)] pub struct WasmRecordTagEntry { @@ -386,6 +416,7 @@ impl From<(String, u64)> for WasmRecordTagEntry { } } +/// JS-friendly view of the trail role map. #[wasm_bindgen(js_name = RoleMap, getter_with_clone, inspectable)] #[derive(Clone)] pub struct WasmRoleMap { @@ -418,6 +449,7 @@ impl From for WasmRoleMap { } } +/// Linked-table metadata keyed by object IDs. #[wasm_bindgen(js_name = ObjectIdLinkedTable, getter_with_clone, inspectable)] #[derive(Clone, Serialize, Deserialize)] pub struct WasmObjectIdLinkedTable { @@ -438,6 +470,7 @@ impl From> for WasmObjectIdLinkedTable { } } +/// Capability issuance options exposed to wasm consumers. #[wasm_bindgen(js_name = CapabilityIssueOptions, getter_with_clone, inspectable)] #[derive(Clone, Default, Serialize, Deserialize)] pub struct WasmCapabilityIssueOptions { @@ -451,6 +484,7 @@ pub struct WasmCapabilityIssueOptions { #[wasm_bindgen(js_class = CapabilityIssueOptions)] impl WasmCapabilityIssueOptions { + /// Creates capability issuance options. #[wasm_bindgen(constructor)] pub fn new(issued_to: Option, valid_from_ms: Option, valid_until_ms: Option) -> Self { Self { @@ -481,6 +515,7 @@ impl From for CapabilityIssueOptions { } } +/// Capability data returned to wasm consumers. #[wasm_bindgen(js_name = Capability, getter_with_clone, inspectable)] #[derive(Clone)] pub struct WasmCapability { @@ -509,6 +544,7 @@ impl From for WasmCapability { } } +/// Event payload emitted when a trail is created. #[wasm_bindgen(js_name = AuditTrailCreated, getter_with_clone, inspectable)] #[derive(Clone)] pub struct WasmAuditTrailCreated { @@ -528,6 +564,7 @@ impl From for WasmAuditTrailCreated { } } +/// Event payload emitted when a trail is deleted. #[wasm_bindgen(js_name = AuditTrailDeleted, getter_with_clone, inspectable)] #[derive(Clone)] pub struct WasmAuditTrailDeleted { @@ -545,6 +582,7 @@ impl From for WasmAuditTrailDeleted { } } +/// Event payload emitted when a record is added. #[wasm_bindgen(js_name = RecordAdded, getter_with_clone, inspectable)] #[derive(Clone)] pub struct WasmRecordAdded { @@ -568,6 +606,7 @@ impl From for WasmRecordAdded { } } +/// Event payload emitted when a record is deleted. #[wasm_bindgen(js_name = RecordDeleted, getter_with_clone, inspectable)] #[derive(Clone)] pub struct WasmRecordDeleted { @@ -591,6 +630,7 @@ impl From for WasmRecordDeleted { } } +/// Event payload emitted when a capability is issued. #[wasm_bindgen(js_name = CapabilityIssued, getter_with_clone, inspectable)] #[derive(Clone)] pub struct WasmCapabilityIssued { @@ -620,6 +660,7 @@ impl From for WasmCapabilityIssued { } } +/// Event payload emitted when a capability is destroyed. #[wasm_bindgen(js_name = CapabilityDestroyed, getter_with_clone, inspectable)] #[derive(Clone)] pub struct WasmCapabilityDestroyed { @@ -649,6 +690,7 @@ impl From for WasmCapabilityDestroyed { } } +/// Event payload emitted when a capability is revoked. #[wasm_bindgen(js_name = CapabilityRevoked, getter_with_clone, inspectable)] #[derive(Clone)] pub struct WasmCapabilityRevoked { @@ -670,6 +712,7 @@ impl From for WasmCapabilityRevoked { } } +/// Event payload emitted when a role is created. #[wasm_bindgen(js_name = RoleCreated, getter_with_clone, inspectable)] #[derive(Clone)] pub struct WasmRoleCreated { @@ -697,6 +740,7 @@ impl From for WasmRoleCreated { } } +/// Event payload emitted when a role is updated. #[wasm_bindgen(js_name = RoleUpdated, getter_with_clone, inspectable)] #[derive(Clone)] pub struct WasmRoleUpdated { @@ -724,6 +768,7 @@ impl From for WasmRoleUpdated { } } +/// Event payload emitted when a role is deleted. #[wasm_bindgen(js_name = RoleDeleted, getter_with_clone, inspectable)] #[derive(Clone)] pub struct WasmRoleDeleted { @@ -746,6 +791,7 @@ impl From for WasmRoleDeleted { } } +/// Discriminant for the shape stored inside [`WasmTimeLock`]. #[wasm_bindgen(js_name = TimeLockType)] #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum WasmTimeLockType { @@ -756,37 +802,44 @@ pub enum WasmTimeLockType { Infinite, } +/// JS-friendly wrapper for time locks. #[wasm_bindgen(js_name = TimeLock, inspectable)] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WasmTimeLock(pub(crate) TimeLock); #[wasm_bindgen(js_class = TimeLock)] impl WasmTimeLock { + /// Creates a lock that unlocks at a Unix timestamp in seconds. #[wasm_bindgen(js_name = withUnlockAt)] pub fn with_unlock_at(time_sec: u32) -> Self { Self(TimeLock::UnlockAt(time_sec)) } + /// Creates a lock that unlocks at a Unix timestamp in milliseconds. #[wasm_bindgen(js_name = withUnlockAtMs)] pub fn with_unlock_at_ms(time_ms: u64) -> Self { Self(TimeLock::UnlockAtMs(time_ms)) } + /// Creates a lock that stays active until the protected object is destroyed. #[wasm_bindgen(js_name = withUntilDestroyed)] pub fn with_until_destroyed() -> Self { Self(TimeLock::UntilDestroyed) } + /// Creates a lock that never unlocks. #[wasm_bindgen(js_name = withInfinite)] pub fn with_infinite() -> Self { Self(TimeLock::Infinite) } + /// Creates a disabled lock. #[wasm_bindgen(js_name = withNone)] pub fn with_none() -> Self { Self(TimeLock::None) } + /// Returns the lock variant. #[wasm_bindgen(js_name = "type", getter)] pub fn lock_type(&self) -> WasmTimeLockType { match self.0 { @@ -798,6 +851,7 @@ impl WasmTimeLock { } } + /// Returns the lock argument for parameterized variants. #[wasm_bindgen(js_name = "args", getter)] pub fn args(&self) -> JsValue { match self.0 { @@ -820,6 +874,7 @@ impl From for TimeLock { } } +/// Discriminant for the shape stored inside [`WasmLockingWindow`]. #[wasm_bindgen(js_name = LockingWindowType)] #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum WasmLockingWindowType { @@ -828,27 +883,32 @@ pub enum WasmLockingWindowType { CountBased, } +/// JS-friendly wrapper for delete windows. #[wasm_bindgen(js_name = LockingWindow, inspectable)] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WasmLockingWindow(pub(crate) LockingWindow); #[wasm_bindgen(js_class = LockingWindow)] impl WasmLockingWindow { + /// Creates a disabled delete window. #[wasm_bindgen(js_name = withNone)] pub fn with_none() -> Self { Self(LockingWindow::None) } + /// Creates a time-based delete window. #[wasm_bindgen(js_name = withTimeBased)] pub fn with_time_based(seconds: u64) -> Self { Self(LockingWindow::TimeBased { seconds }) } + /// Creates a count-based delete window. #[wasm_bindgen(js_name = withCountBased)] pub fn with_count_based(count: u64) -> Self { Self(LockingWindow::CountBased { count }) } + /// Returns the window variant. #[wasm_bindgen(js_name = "type", getter)] pub fn window_type(&self) -> WasmLockingWindowType { match self.0 { @@ -858,6 +918,7 @@ impl WasmLockingWindow { } } + /// Returns the window argument for parameterized variants. #[wasm_bindgen(js_name = "args", getter)] pub fn args(&self) -> JsValue { match self.0 { @@ -880,6 +941,7 @@ impl From for LockingWindow { } } +/// Full locking configuration exposed to wasm consumers. #[wasm_bindgen(js_name = LockingConfig, getter_with_clone, inspectable)] #[derive(Clone, Serialize, Deserialize)] pub struct WasmLockingConfig { @@ -893,6 +955,7 @@ pub struct WasmLockingConfig { #[wasm_bindgen(js_class = LockingConfig)] impl WasmLockingConfig { + /// Creates a locking configuration. #[wasm_bindgen(constructor)] pub fn new( delete_record_window: WasmLockingWindow, @@ -927,6 +990,7 @@ impl From for LockingConfig { } } +/// Immutable trail metadata exposed to wasm consumers. #[wasm_bindgen(js_name = ImmutableMetadata, getter_with_clone, inspectable)] #[derive(Clone, Serialize, Deserialize)] pub struct WasmImmutableMetadata { @@ -952,6 +1016,7 @@ impl From for ImmutableMetadata { } } +/// Correction metadata attached to a record. #[wasm_bindgen(js_name = RecordCorrection, getter_with_clone, inspectable)] #[derive(Clone, Serialize, Deserialize)] pub struct WasmRecordCorrection { @@ -980,6 +1045,7 @@ impl From for RecordCorrection { } } +/// Single audit-trail record exposed to wasm consumers. #[wasm_bindgen(js_name = Record, getter_with_clone, inspectable)] #[derive(Clone)] pub struct WasmRecord { @@ -1009,6 +1075,7 @@ impl From> for WasmRecord { } } +/// One page of records returned by `TrailRecords.listPage(...)`. #[wasm_bindgen(js_name = PaginatedRecord, getter_with_clone, inspectable)] #[derive(Clone)] pub struct WasmPaginatedRecord { diff --git a/notarization-move/README.md b/notarization-move/README.md new file mode 100644 index 00000000..28e54da6 --- /dev/null +++ b/notarization-move/README.md @@ -0,0 +1,86 @@ +![banner](https://github.com/iotaledger/notarization/raw/HEAD/.github/banner_notarization.png) + +

+ StackExchange + Discord + Apache 2.0 license +

+ +

+ Introduction ◈ + Modules ◈ + Development & Testing ◈ + Related Libraries ◈ + Contributing +

+ +--- + +# IOTA Notarization Move Package + +## Introduction + +`notarization-move` is the on-chain Move package behind IOTA Notarization. + +It defines the core `Notarization` object and the supporting modules for: + +- dynamic notarization flows +- locked notarization flows +- immutable creation metadata +- optional updatable metadata +- state updates, transfer rules, and destruction checks +- emitted events for notarization lifecycle changes + +The package depends on `TfComponents` for shared timelock primitives. + +## Modules + +- `iota_notarization::notarization` + Core object, state model, metadata, lock metadata, updates, and destruction logic. +- `iota_notarization::dynamic_notarization` + Dynamic notarization creation and transfer flows. +- `iota_notarization::locked_notarization` + Locked notarization creation flows with timelock controls. +- `iota_notarization::method` + Method discriminator helpers for dynamic and locked variants. + +## Development And Testing + +Build the Move package: + +```bash +cd notarization-move +iota move build +``` + +Run the Move test suite: + +```bash +cd notarization-move +iota move test +``` + +Publish locally: + +```bash +cd notarization-move +./scripts/publish_package.sh +``` + +The package history files [`Move.lock`](./Move.lock) and [`Move.history.json`](./Move.history.json) are used by the Rust SDK to resolve and track deployed package versions. + +## Related Libraries + +- [Rust SDK](https://github.com/iotaledger/notarization/tree/main/notarization-rs/README.md) +- [Wasm SDK](https://github.com/iotaledger/notarization/tree/main/bindings/wasm/notarization_wasm/README.md) +- [Repository Root](https://github.com/iotaledger/notarization/tree/main/README.md) + +## Contributing + +We would love to have you help us with the development of IOTA Notarization. Each and every contribution is greatly valued. + +Please review the [contribution](https://docs.iota.org/developer/iota-notarization/contribute) sections in the [IOTA Docs Portal](https://docs.iota.org/developer/iota-notarization/). + +To contribute directly to the repository, simply fork the project, push your changes to your fork and create a pull request to get them included. + +The best place to get involved in discussions about this package or to look for support at is the `#notarization` channel on the [IOTA Discord](https://discord.gg/iota-builders). You can also ask questions on our [Stack Exchange](https://iota.stackexchange.com/). From fe9a5c6cd53e8066c68195dbd2f43a9dacac656d Mon Sep 17 00:00:00 2001 From: Yasir Date: Wed, 1 Apr 2026 17:08:25 +0300 Subject: [PATCH 124/189] docs: polish readme formatting --- README.md | 6 +++--- audit-trail-rs/README.md | 2 +- audit-trail-rs/src/core/types/record.rs | 6 +++++- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 4244a4df..dd03c1ba 100644 --- a/README.md +++ b/README.md @@ -36,10 +36,10 @@ Each toolkit is split into: ## Packages -| Toolkit | Move Package | Rust SDK | Wasm SDK | -| ------- | ------------ | -------- | -------- | +| Toolkit | Move Package | Rust SDK | Wasm SDK | +| ------------ | ------------------------------------------ | -------------------------------------- | ---------------------------------------------------------------------- | | Notarization | [`notarization-move`](./notarization-move) | [`notarization-rs`](./notarization-rs) | [`bindings/wasm/notarization_wasm`](./bindings/wasm/notarization_wasm) | -| Audit Trail | [`audit-trail-move`](./audit-trail-move) | [`audit-trail-rs`](./audit-trail-rs) | [`bindings/wasm/audit_trail_wasm`](./bindings/wasm/audit_trail_wasm) | +| Audit Trail | [`audit-trail-move`](./audit-trail-move) | [`audit-trail-rs`](./audit-trail-rs) | [`bindings/wasm/audit_trail_wasm`](./bindings/wasm/audit_trail_wasm) | ## Documentation And Resources diff --git a/audit-trail-rs/README.md b/audit-trail-rs/README.md index 45b98fa9..df8e25d2 100644 --- a/audit-trail-rs/README.md +++ b/audit-trail-rs/README.md @@ -96,4 +96,4 @@ Please review the [contribution](https://docs.iota.org/developer/iota-notarizati To contribute directly to the repository, simply fork the project, push your changes to your fork and create a pull request to get them included. -The best place to get involved in discussions about this library or to look for support at is the `#notarization` channel on the [IOTA Discord](https://discord.gg/iota-builders). You can also ask questions on our [Stack Exchange](https://iota.stackexchange.com/). \ No newline at end of file +The best place to get involved in discussions about this library or to look for support at is the `#notarization` channel on the [IOTA Discord](https://discord.gg/iota-builders). You can also ask questions on our [Stack Exchange](https://iota.stackexchange.com/). diff --git a/audit-trail-rs/src/core/types/record.rs b/audit-trail-rs/src/core/types/record.rs index 6b85c227..e18e1ff2 100644 --- a/audit-trail-rs/src/core/types/record.rs +++ b/audit-trail-rs/src/core/types/record.rs @@ -63,7 +63,11 @@ impl InitialRecord { /// ```rust /// use audit_trail::core::types::{Data, InitialRecord}; /// - /// let record = InitialRecord::new(Data::text("hello"), Some("seed".to_string()), Some("inbox".to_string())); + /// let record = InitialRecord::new( + /// Data::text("hello"), + /// Some("seed".to_string()), + /// Some("inbox".to_string()), + /// ); /// /// assert_eq!(record.data, Data::text("hello")); /// assert_eq!(record.metadata.as_deref(), Some("seed")); From 6d205b9e5c49489aee9a3b08713498fdfd223683 Mon Sep 17 00:00:00 2001 From: chrisgitiota Date: Wed, 1 Apr 2026 17:12:13 +0200 Subject: [PATCH 125/189] Move Notarization examples into subfolder --- examples/Cargo.toml | 20 +++++++++---------- examples/README.md | 20 +++++++++---------- .../01_create_locked_notarization.rs | 0 .../02_create_dynamic_notarization.rs | 0 .../03_update_dynamic_notarization.rs | 0 .../04_destroy_notarization.rs | 0 .../{ => notarization}/05_update_state.rs | 0 .../{ => notarization}/06_update_metadata.rs | 0 .../07_transfer_dynamic_notarization.rs | 0 .../08_access_read_only_methods.rs | 0 .../real-world/01_iot_weather_station.rs | 0 .../real-world/02_legal_contract.rs | 0 12 files changed, 20 insertions(+), 20 deletions(-) rename examples/{ => notarization}/01_create_locked_notarization.rs (100%) rename examples/{ => notarization}/02_create_dynamic_notarization.rs (100%) rename examples/{ => notarization}/03_update_dynamic_notarization.rs (100%) rename examples/{ => notarization}/04_destroy_notarization.rs (100%) rename examples/{ => notarization}/05_update_state.rs (100%) rename examples/{ => notarization}/06_update_metadata.rs (100%) rename examples/{ => notarization}/07_transfer_dynamic_notarization.rs (100%) rename examples/{ => notarization}/08_access_read_only_methods.rs (100%) rename examples/{ => notarization}/real-world/01_iot_weather_station.rs (100%) rename examples/{ => notarization}/real-world/02_legal_contract.rs (100%) diff --git a/examples/Cargo.toml b/examples/Cargo.toml index 80df3ddb..63cb96ad 100644 --- a/examples/Cargo.toml +++ b/examples/Cargo.toml @@ -10,43 +10,43 @@ path = "utils/utils.rs" [[example]] name = "01_create_locked_notarization" -path = "01_create_locked_notarization.rs" +path = "notarization/01_create_locked_notarization.rs" [[example]] name = "02_create_dynamic_notarization" -path = "02_create_dynamic_notarization.rs" +path = "notarization/02_create_dynamic_notarization.rs" [[example]] name = "03_update_dynamic_notarization" -path = "03_update_dynamic_notarization.rs" +path = "notarization/03_update_dynamic_notarization.rs" [[example]] name = "04_destroy_notarization" -path = "04_destroy_notarization.rs" +path = "notarization/04_destroy_notarization.rs" [[example]] name = "05_update_state" -path = "05_update_state.rs" +path = "notarization/05_update_state.rs" [[example]] name = "06_update_metadata" -path = "06_update_metadata.rs" +path = "notarization/06_update_metadata.rs" [[example]] name = "07_transfer_dynamic_notarization" -path = "07_transfer_dynamic_notarization.rs" +path = "notarization/07_transfer_dynamic_notarization.rs" [[example]] name = "08_access_read_only_methods" -path = "08_access_read_only_methods.rs" +path = "notarization/08_access_read_only_methods.rs" [[example]] name = "01_iot_weather_station" -path = "real-world/01_iot_weather_station.rs" +path = "notarization/real-world/01_iot_weather_station.rs" [[example]] name = "02_legal_contract" -path = "real-world/02_legal_contract.rs" +path = "notarization/real-world/02_legal_contract.rs" [dependencies] anyhow.workspace = true diff --git a/examples/README.md b/examples/README.md index 783c5af1..c82dfdef 100644 --- a/examples/README.md +++ b/examples/README.md @@ -44,14 +44,14 @@ The following basic CRUD (Create, Read, Update, Delete) examples are available: | Name | Information | | :------------------------------------------------------------------------------------------------------------------------------------ | :-------------------------------------------------------------------------------- | -| [01_create_locked_notarization](https://github.com/iotaledger/notarization/tree/main/examples/01_create_locked_notarization.rs) | Demonstrates how to create a locked notarization with delete locks. | -| [02_create_dynamic_notarization](https://github.com/iotaledger/notarization/tree/main/examples/02_create_dynamic_notarization.rs) | Demonstrates how to create dynamic notarizations with and without transfer locks. | -| [03_update_dynamic_notarization](https://github.com/iotaledger/notarization/tree/main/examples/03_update_dynamic_notarization.rs) | Demonstrates that dynamic notarizations can be updated | -| [04_destroy_notarization](https://github.com/iotaledger/notarization/tree/main/examples/04_destroy_notarization.rs) | Demonstrates notarization destruction scenarios based on lock types. | -| [05_update_state](https://github.com/iotaledger/notarization/tree/main/examples/05_update_state.rs) | Demonstrates state updates on dynamic notarizations including binary data. | -| [06_update_metadata](https://github.com/iotaledger/notarization/tree/main/examples/06_update_metadata.rs) | Demonstrates metadata updates and their behavior vs state updates. | -| [07_transfer_dynamic_notarization](https://github.com/iotaledger/notarization/tree/main/examples/07_transfer_dynamic_notarization.rs) | Demonstrates transfer scenarios for different notarization types and lock states. | -| [08_access_read_only_methods](https://github.com/iotaledger/notarization/tree/main/examples/08_access_read_only_methods.rs) | Comprehensive demonstration of all read-only inspection methods. | +| [01_create_locked_notarization](https://github.com/iotaledger/notarization/tree/main/examples/notarization/01_create_locked_notarization.rs) | Demonstrates how to create a locked notarization with delete locks. | +| [02_create_dynamic_notarization](https://github.com/iotaledger/notarization/tree/main/examples/notarization/02_create_dynamic_notarization.rs) | Demonstrates how to create dynamic notarizations with and without transfer locks. | +| [03_update_dynamic_notarization](https://github.com/iotaledger/notarization/tree/main/examples/notarization/03_update_dynamic_notarization.rs) | Demonstrates that dynamic notarizations can be updated | +| [04_destroy_notarization](https://github.com/iotaledger/notarization/tree/main/examples/notarization/04_destroy_notarization.rs) | Demonstrates notarization destruction scenarios based on lock types. | +| [05_update_state](https://github.com/iotaledger/notarization/tree/main/examples/notarization/05_update_state.rs) | Demonstrates state updates on dynamic notarizations including binary data. | +| [06_update_metadata](https://github.com/iotaledger/notarization/tree/main/examples/notarization/06_update_metadata.rs) | Demonstrates metadata updates and their behavior vs state updates. | +| [07_transfer_dynamic_notarization](https://github.com/iotaledger/notarization/tree/main/examples/notarization/07_transfer_dynamic_notarization.rs) | Demonstrates transfer scenarios for different notarization types and lock states. | +| [08_access_read_only_methods](https://github.com/iotaledger/notarization/tree/main/examples/notarization/08_access_read_only_methods.rs) | Comprehensive demonstration of all read-only inspection methods. | ## Real-World Examples @@ -59,8 +59,8 @@ The following examples demonstrate practical use cases with proper field usage: | Name | Information | | :--------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------------------------- | -| [iot_weather_station](https://github.com/iotaledger/notarization/tree/main/examples/real-world/iot_weather_station.rs) | IoT weather station using dynamic notarization for continuous sensor data updates. | -| [legal_contract](https://github.com/iotaledger/notarization/tree/main/examples/real-world/legal_contract.rs) | Legal contract using locked notarization for immutable document hash attestation. | +| [iot_weather_station](https://github.com/iotaledger/notarization/tree/main/examples/notarization/real-world/iot_weather_station.rs) | IoT weather station using dynamic notarization for continuous sensor data updates. | +| [legal_contract](https://github.com/iotaledger/notarization/tree/main/examples/notarization/real-world/legal_contract.rs) | Legal contract using locked notarization for immutable document hash attestation. | ## Notarization Types diff --git a/examples/01_create_locked_notarization.rs b/examples/notarization/01_create_locked_notarization.rs similarity index 100% rename from examples/01_create_locked_notarization.rs rename to examples/notarization/01_create_locked_notarization.rs diff --git a/examples/02_create_dynamic_notarization.rs b/examples/notarization/02_create_dynamic_notarization.rs similarity index 100% rename from examples/02_create_dynamic_notarization.rs rename to examples/notarization/02_create_dynamic_notarization.rs diff --git a/examples/03_update_dynamic_notarization.rs b/examples/notarization/03_update_dynamic_notarization.rs similarity index 100% rename from examples/03_update_dynamic_notarization.rs rename to examples/notarization/03_update_dynamic_notarization.rs diff --git a/examples/04_destroy_notarization.rs b/examples/notarization/04_destroy_notarization.rs similarity index 100% rename from examples/04_destroy_notarization.rs rename to examples/notarization/04_destroy_notarization.rs diff --git a/examples/05_update_state.rs b/examples/notarization/05_update_state.rs similarity index 100% rename from examples/05_update_state.rs rename to examples/notarization/05_update_state.rs diff --git a/examples/06_update_metadata.rs b/examples/notarization/06_update_metadata.rs similarity index 100% rename from examples/06_update_metadata.rs rename to examples/notarization/06_update_metadata.rs diff --git a/examples/07_transfer_dynamic_notarization.rs b/examples/notarization/07_transfer_dynamic_notarization.rs similarity index 100% rename from examples/07_transfer_dynamic_notarization.rs rename to examples/notarization/07_transfer_dynamic_notarization.rs diff --git a/examples/08_access_read_only_methods.rs b/examples/notarization/08_access_read_only_methods.rs similarity index 100% rename from examples/08_access_read_only_methods.rs rename to examples/notarization/08_access_read_only_methods.rs diff --git a/examples/real-world/01_iot_weather_station.rs b/examples/notarization/real-world/01_iot_weather_station.rs similarity index 100% rename from examples/real-world/01_iot_weather_station.rs rename to examples/notarization/real-world/01_iot_weather_station.rs diff --git a/examples/real-world/02_legal_contract.rs b/examples/notarization/real-world/02_legal_contract.rs similarity index 100% rename from examples/real-world/02_legal_contract.rs rename to examples/notarization/real-world/02_legal_contract.rs From 4dc54fc23bc69c9fcc9747ffa18716d8cba107c5 Mon Sep 17 00:00:00 2001 From: chrisgitiota Date: Wed, 1 Apr 2026 17:12:37 +0200 Subject: [PATCH 126/189] New CLAUDE.md file --- CLAUDE.md | 116 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..dab30dc2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,116 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +IOTA Notarization enables creation of immutable, on-chain records for arbitrary data by storing it (or a hash) in dedicated Move objects on the IOTA ledger. The workspace has two main subsystems: **Notarization** (creating tamper-proof records) and **Audit Trails** (structured, role-based audit logging). + +## Common Commands + +### Build & Check +```bash +cargo build --workspace --tests --examples +cargo check -p notarization-rs +cargo check -p audit-trail-rs +``` + +### Test +```bash +# Tests must run single-threaded (IOTA sandbox requirement) +cargo test --workspace --release -- --test-threads=1 + +# Single test +cargo test --release -p notarization-rs test_name -- --test-threads=1 + +# Move contract tests (from notarization-move/ or audit-trail-move/) +iota move test +``` + +### Lint & Format +```bash +cargo clippy --all-targets --all-features +cargo fmt --all +cargo fmt --all -- --check # check only +``` + +### WASM Bindings (in bindings/wasm/notarization_wasm/ or audit_trail_wasm/) +```bash +npm install +npm run build +npm test # Node.js tests +npm run test:browser # Cypress browser tests +``` + +### Move Scripts +```bash +# From notarization-move/ or audit-trail-move/ +./scripts/publish_package.sh +./scripts/notarize.sh +``` + +### Running Examples +Examples require the notarization package to be published first. From the repo root: +```bash +# Publish the package and capture the package ID +export IOTA_NOTARIZATION_PKG_ID=$(./notarization-move/scripts/publish_package.sh) + +# Run a specific example +cargo run --release --example +``` +To run all examples. From the repo root:: +```bash +# Make sure IOTA_NOTARIZATION_PKG_ID is set as shown above +./examples/run.sh +``` + +## Workspace Structure + +The root `Cargo.toml` defines a workspace with members: `notarization-rs`, `audit-trail-rs`, `examples`. The WASM crates (`bindings/wasm/*`) are excluded from the workspace and built separately. + +- **`notarization-rs/`** — Rust client library for notarization +- **`notarization-move/`** — Move smart contracts for notarization +- **`audit-trail-rs/`** — Rust client library for audit trails +- **`audit-trail-move/`** — Move smart contracts for audit trails +- **`bindings/wasm/notarization_wasm/`** — JS/TS WASM bindings for notarization +- **`bindings/wasm/audit_trail_wasm/`** — JS/TS WASM bindings for audit trails +- **`examples/`** — Rust examples (basic CRUD + real-world scenarios like IoT, legal contracts) + +## Architecture + +### Client Layer Pattern +Both `notarization-rs` and `audit-trail-rs` follow the same pattern: +- **Full client** (`NotarizationClient` / `AuditTrailClient`): Signs and submits transactions +- **Read-only client** (`NotarizationClientReadOnly` / `AuditTrailClientReadOnly`): Read-only state inspection +- Clients wrap a `product_common` transaction builder that supports `.build()`, `.build_and_execute()`, and `.execute_with_gas_station()` + +### Builder Pattern (Type-State) +Notarization creation uses a `NotarizationBuilder` with phantom type states to enforce valid configurations at compile time. Separate builder paths exist for **Dynamic** (mutable, transferable) vs **Locked** (immutable, non-transferable) notarizations. + +### Method Types +- **Dynamic**: State and metadata are updatable after creation; supports transfer locks +- **Locked**: State and metadata are immutable; supports time-based destruction + +### Lock System +- **Transfer locks**: `None`, `UnlockAt(epoch)`, `UntilDestroyed` +- **Delete locks**: Restrict when a notarization can be destroyed + +### Cross-Platform Compilation +Code uses `#[cfg(target_arch = "wasm32")]` guards to conditionally compile for WASM. Features `send-sync`, `gas-station`, `default-http-client`, and `irl` control optional capabilities. + +### Key External Dependencies +- `iota-sdk` (v1.19.1, from IOTA git) — on-chain interaction +- `iota_interaction` / `iota_interaction_rust` / `iota_interaction_ts` — from `product-core` repo, `feat/tf-compoenents-dev` branch +- `product_common` — transaction builder abstraction from `product-core` +- `secret-storage` (v0.3.0) — key management + +## Testing Requirements + +- Tests require an IOTA sandbox running locally +- Always use `--test-threads=1` (tests share sandbox state) +- Examples require `IOTA_NOTARIZATION_PKG_ID` environment variable set to the deployed package ID +- WASM browser tests use Cypress + +## Rust Version + +Minimum: **1.85**, Edition: **2024** From 8b268a182d010c8ba097c353d7c24fd7f1be5dfe Mon Sep 17 00:00:00 2001 From: Yasir Date: Thu, 2 Apr 2026 11:49:13 +0300 Subject: [PATCH 127/189] docs: document audit trail internal helpers --- .../src/core/internal/capability.rs | 18 ++++++++++++++++ .../src/core/internal/linked_table.rs | 6 ++++++ audit-trail-rs/src/core/internal/mod.rs | 8 +++++++ .../src/core/internal/move_collections.rs | 7 +++++++ audit-trail-rs/src/core/internal/trail.rs | 3 +++ audit-trail-rs/src/core/internal/tx.rs | 21 +++++++++++++++++++ 6 files changed, 63 insertions(+) diff --git a/audit-trail-rs/src/core/internal/capability.rs b/audit-trail-rs/src/core/internal/capability.rs index 440a043d..d7449a33 100644 --- a/audit-trail-rs/src/core/internal/capability.rs +++ b/audit-trail-rs/src/core/internal/capability.rs @@ -1,6 +1,8 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +//! Capability discovery helpers used by internal transaction builders. + use std::collections::{BTreeMap, HashSet}; use iota_interaction::move_types::language_storage::StructTag; @@ -18,6 +20,10 @@ use super::{linked_table, tx}; use crate::core::types::{Capability, OnChainAuditTrail, Permission}; use crate::error::Error; +/// Finds an owned capability object that grants `permission` for `trail_id` and returns its object +/// reference. +/// +/// The lookup is restricted to roles on `trail` that include the requested permission. pub(crate) async fn find_capable_cap( client: &C, owner: IotaAddress, @@ -51,6 +57,10 @@ where tx::get_object_ref_by_id(client, &object_id).await } +/// Searches the owner's capability objects and returns the first one matching `predicate`. +/// +/// Revoked capabilities are filtered out before the predicate is applied to the remaining +/// candidates. pub(crate) async fn find_owned_capability( client: &C, owner: IotaAddress, @@ -107,6 +117,10 @@ where Ok(None) } +/// Traverses the revoked-capabilities linked table and collects every revoked capability ID. +/// +/// The traversal validates that the linked-table shape is acyclic and that the number of visited +/// entries matches the size recorded on-chain. async fn revoked_capability_ids(client: &C, trail: &OnChainAuditTrail) -> Result, Error> where C: CoreClientReadOnly + OptionalSync, @@ -150,6 +164,10 @@ where Ok(keys) } +/// Returns whether a capability is a usable match for the current owner and predicate. +/// +/// A capability only matches when it satisfies the caller-provided predicate, has not been +/// revoked, and is either unbound or explicitly issued to `owner`. fn capability_matches

( cap: &Capability, owner: IotaAddress, diff --git a/audit-trail-rs/src/core/internal/linked_table.rs b/audit-trail-rs/src/core/internal/linked_table.rs index 2ec47849..7f3f4c85 100644 --- a/audit-trail-rs/src/core/internal/linked_table.rs +++ b/audit-trail-rs/src/core/internal/linked_table.rs @@ -1,6 +1,8 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +//! Helpers for reading Move `LinkedTable` nodes through dynamic fields. + use iota_interaction::rpc_types::{IotaData as _, IotaObjectDataOptions}; use iota_interaction::types::base_types::ObjectID; use iota_interaction::types::collection_types::LinkedTableNode; @@ -11,6 +13,10 @@ use serde::de::DeserializeOwned; use crate::error::Error; +/// Fetches and decodes a single linked-table node stored as a dynamic field under `table_id`. +/// +/// The caller provides the fully encoded Move field name so this helper can stay generic over the +/// linked-table key and value types. pub(crate) async fn fetch_node( client: &C, table_id: ObjectID, diff --git a/audit-trail-rs/src/core/internal/mod.rs b/audit-trail-rs/src/core/internal/mod.rs index 44b8c792..c4409bcb 100644 --- a/audit-trail-rs/src/core/internal/mod.rs +++ b/audit-trail-rs/src/core/internal/mod.rs @@ -1,8 +1,16 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +//! Internal helpers used to bridge public audit-trail APIs to low-level IOTA object access and +//! programmable transaction construction. + +/// Capability lookup helpers for trail-scoped permission checks. pub(crate) mod capability; +/// Linked-table decoding helpers for traversing on-chain Move collections. pub(crate) mod linked_table; +/// Serde adapters for Move collection types that are exposed as standard Rust collections. pub(crate) mod move_collections; +/// Raw trail fetch and decode helpers. pub(crate) mod trail; +/// Common programmable-transaction building helpers. pub(crate) mod tx; diff --git a/audit-trail-rs/src/core/internal/move_collections.rs b/audit-trail-rs/src/core/internal/move_collections.rs index 9df1cc84..ba31be21 100644 --- a/audit-trail-rs/src/core/internal/move_collections.rs +++ b/audit-trail-rs/src/core/internal/move_collections.rs @@ -1,6 +1,8 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +//! Serde adapters for decoding Move collection wrappers into standard Rust collections. + use std::collections::{HashMap, HashSet}; use std::fmt::Debug; use std::hash::Hash; @@ -8,6 +10,10 @@ use std::hash::Hash; use iota_interaction::types::collection_types::{VecMap, VecSet}; use serde::{Deserialize, Deserializer}; +/// Deserializes a Move `VecMap` into a Rust [`HashMap`]. +/// +/// This adapter is used on public domain types that expose map-like data as idiomatic Rust +/// collections while preserving the on-chain wire format. pub(crate) fn deserialize_vec_map<'de, D, K, V>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, @@ -22,6 +28,7 @@ where .collect()) } +/// Deserializes a Move `VecSet` into a Rust [`HashSet`]. pub(crate) fn deserialize_vec_set<'de, D, T>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, diff --git a/audit-trail-rs/src/core/internal/trail.rs b/audit-trail-rs/src/core/internal/trail.rs index cd1ddee2..d90861b8 100644 --- a/audit-trail-rs/src/core/internal/trail.rs +++ b/audit-trail-rs/src/core/internal/trail.rs @@ -1,6 +1,8 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +//! Helpers for fetching and decoding the shared on-chain audit-trail object. + use iota_interaction::rpc_types::{IotaData as _, IotaObjectDataOptions}; use iota_interaction::types::base_types::ObjectID; use iota_interaction::{IotaClientTrait, OptionalSync}; @@ -9,6 +11,7 @@ use product_common::core_client::CoreClientReadOnly; use crate::core::types::OnChainAuditTrail; use crate::error::Error; +/// Loads the shared audit-trail object and decodes it into [`OnChainAuditTrail`]. pub(crate) async fn get_audit_trail(trail_id: ObjectID, client: &C) -> Result where C: CoreClientReadOnly + OptionalSync, diff --git a/audit-trail-rs/src/core/internal/tx.rs b/audit-trail-rs/src/core/internal/tx.rs index d379536d..b5e2c88b 100644 --- a/audit-trail-rs/src/core/internal/tx.rs +++ b/audit-trail-rs/src/core/internal/tx.rs @@ -1,6 +1,8 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +//! Shared transaction-building helpers used by the internal audit-trail operations. + use std::str::FromStr; use iota_interaction::rpc_types::IotaObjectDataOptions; @@ -21,6 +23,7 @@ use super::{capability, trail as trail_reader}; use crate::core::types::Permission; use crate::error::Error; +/// Returns the canonical immutable clock object argument. pub(crate) fn get_clock_ref(ptb: &mut Ptb) -> Argument { ptb.obj(ObjectArg::SharedObject { id: IOTA_CLOCK_OBJECT_ID, @@ -30,6 +33,8 @@ pub(crate) fn get_clock_ref(ptb: &mut Ptb) -> Argument { .expect("network has a singleton clock instantiated") } +/// Serializes a pure programmable-transaction argument and annotates serialization failures with +/// the logical argument name. pub(crate) fn ptb_pure(ptb: &mut Ptb, name: &str, value: T) -> Result where T: Serialize + core::fmt::Debug, @@ -41,6 +46,7 @@ where }) } +/// Wraps an optional argument into the corresponding Move `std::option::Option` value. pub(crate) fn option_to_move( option: Option, tag: TypeTag, @@ -67,6 +73,8 @@ pub(crate) fn option_to_move( Ok(arg) } +/// Builds a writable trail transaction after resolving both the trail object and a matching +/// capability for `owner`. pub(crate) async fn build_trail_transaction( client: &C, trail_id: ObjectID, @@ -84,6 +92,8 @@ where build_trail_transaction_with_cap_ref(client, trail_id, cap_ref, method, additional_args).await } +/// Builds a writable trail transaction when the caller already has the capability object +/// reference. pub(crate) async fn build_trail_transaction_with_cap_ref( client: &C, trail_id: ObjectID, @@ -118,6 +128,7 @@ where Ok(ptb.finish()) } +/// Builds a read-only trail transaction that borrows the shared trail object immutably. pub(crate) async fn build_read_only_transaction( client: &C, trail_id: ObjectID, @@ -148,6 +159,10 @@ where Ok(ptb.finish()) } +/// Extracts the generic record payload type from the on-chain trail object type. +/// +/// Audit-trail Move entry points are generic over the record payload type, so transaction builders +/// need this type tag to invoke the correct specialization. pub(crate) async fn get_type_tag(client: &C, object_id: &ObjectID) -> Result where C: CoreClientReadOnly + OptionalSync, @@ -174,6 +189,7 @@ where .map_err(|e| Error::FailedToParseTag(format!("Failed to parse tag '{type_param_str}': {e}"))) } +/// Extracts the innermost generic type parameter from a full Move object type string. fn parse_type(full_type: &str) -> Result { if let (Some(start), Some(end)) = (full_type.find('<'), full_type.rfind('>')) { Ok(full_type[start + 1..end].to_string()) @@ -184,6 +200,7 @@ fn parse_type(full_type: &str) -> Result { } } +/// Fetches the current object reference for `object_id`. pub(crate) async fn get_object_ref_by_id( client: &impl CoreClientReadOnly, object_id: &ObjectID, @@ -202,6 +219,10 @@ pub(crate) async fn get_object_ref_by_id( Ok(data.object_ref()) } +/// Resolves a shared object argument for use in a programmable transaction. +/// +/// This validates that the fetched object is shared and returns the appropriate mutability flag for +/// the planned call. pub(crate) async fn get_shared_object_arg( client: &impl CoreClientReadOnly, object_id: &ObjectID, From 7ad26573a4ed4d9b4c443c4609c3331fdce53044 Mon Sep 17 00:00:00 2001 From: Yasir Date: Thu, 2 Apr 2026 13:38:46 +0300 Subject: [PATCH 128/189] docs: refine crate documentation --- .cargo/config.toml | 3 + README.md | 104 +++++++++++++----- audit-trail-rs/README.md | 38 ------- audit-trail-rs/src/core/access/operations.rs | 7 ++ audit-trail-rs/src/core/create/operations.rs | 18 +++ audit-trail-rs/src/core/locking/operations.rs | 3 + audit-trail-rs/src/core/mod.rs | 16 +-- audit-trail-rs/src/core/records/operations.rs | 7 ++ audit-trail-rs/src/core/tags/operations.rs | 3 + audit-trail-rs/src/core/trail/operations.rs | 3 + .../src/iota_interaction_adapter.rs | 7 +- notarization-rs/README.md | 31 ------ notarization-rs/src/lib.rs | 2 + 13 files changed, 134 insertions(+), 108 deletions(-) create mode 100644 .cargo/config.toml diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 00000000..3f2baa11 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,3 @@ +[alias] +docs-notarization = "doc -p notarization" +docs-audit-trail = "doc -p audit_trail" diff --git a/README.md b/README.md index dd03c1ba..6f64c2b4 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,8 @@

Introduction ◈ - Packages ◈ + Where To Start ◈ + ToolkitsDocumentation & ResourcesBindingsContributing @@ -21,47 +22,94 @@ ## Introduction -This repository contains two complementary IOTA ledger toolkits: +This repository contains two complementary IOTA ledger toolkits for verifiable on-chain data workflows: - **IOTA Notarization** - Creates verifiable on-chain proof objects for arbitrary data, including dynamic and locked notarization flows. + Best when you want a proof object for arbitrary data, documents, hashes, or latest-state notarization flows. - **IOTA Audit Trail** - Creates shared on-chain audit trails with sequential records, role-based access control, locking, and tagging. + Best when you want shared audit records with sequential entries, role-based access control, locking, and tagging. -Each toolkit is split into: +Each toolkit is available as: -- a Move package that defines the on-chain object model and behavior -- a Rust SDK that provides typed client access and transaction builders -- wasm bindings for JavaScript and TypeScript integrations +- a **Move package** for the on-chain contracts +- a **Rust SDK** for typed client access and transaction builders +- **wasm bindings** for JavaScript and TypeScript integrations -## Packages +## Where To Start -| Toolkit | Move Package | Rust SDK | Wasm SDK | -| ------------ | ------------------------------------------ | -------------------------------------- | ---------------------------------------------------------------------- | -| Notarization | [`notarization-move`](./notarization-move) | [`notarization-rs`](./notarization-rs) | [`bindings/wasm/notarization_wasm`](./bindings/wasm/notarization_wasm) | -| Audit Trail | [`audit-trail-move`](./audit-trail-move) | [`audit-trail-rs`](./audit-trail-rs) | [`bindings/wasm/audit_trail_wasm`](./bindings/wasm/audit_trail_wasm) | +### I want to notarize data + +Use **IOTA Notarization** when your main need is proving the existence, integrity, or latest state of data on-chain. + +- [Notarization Rust SDK](./notarization-rs) +- [Notarization Move Package](./notarization-move) +- [Notarization Wasm SDK](./bindings/wasm/notarization_wasm) +- [Notarization examples](./bindings/wasm/notarization_wasm/examples/README.md) + +### I want audit records + +Use **IOTA Audit Trail** when you need shared audit records with permissions, capabilities, tagging, and write or delete controls. + +- [Audit Trail Rust SDK](./audit-trail-rs) +- [Audit Trail Move Package](./audit-trail-move) +- [Audit Trail Wasm SDK](./bindings/wasm/audit_trail_wasm) +- [Audit Trail examples](./bindings/wasm/audit_trail_wasm/examples/README.md) + +### I want the on-chain contracts + +- [Notarization Move](./notarization-move) +- [Audit Trail Move](./audit-trail-move) + +### I want application SDKs + +- [Notarization Rust](./notarization-rs) +- [Audit Trail Rust](./audit-trail-rs) +- [Notarization Wasm](./bindings/wasm/notarization_wasm) +- [Audit Trail Wasm](./bindings/wasm/audit_trail_wasm) + +## Toolkits + +| Toolkit | Best for | Move Package | Rust SDK | Wasm SDK | +| ------- | -------- | ------------ | -------- | -------- | +| Notarization | Proof objects for documents, hashes, and updatable notarized state | [`notarization-move`](./notarization-move) | [`notarization-rs`](./notarization-rs) | [`notarization_wasm`](./bindings/wasm/notarization_wasm) | +| Audit Trail | Shared sequential records with roles, capabilities, tagging, and locking | [`audit-trail-move`](./audit-trail-move) | [`audit-trail-rs`](./audit-trail-rs) | [`audit_trail_wasm`](./bindings/wasm/audit_trail_wasm) | + +### Which one should I use? + +| Need | Best fit | +| ---- | -------- | +| Immutable or updatable proof object for arbitrary data | Notarization | +| Simple proof-of-existence or latest-state notarization flow | Notarization | +| Shared sequential records with roles, capabilities, and record tag policy | Audit Trail | +| Team or system audit log with governance and operational controls | Audit Trail | ## Documentation And Resources -- IOTA Notarization: - - [Notarization Rust SDK README](https://github.com/iotaledger/notarization/tree/main/notarization-rs/README.md) - - [Notarization Wasm README](https://github.com/iotaledger/notarization/tree/main/bindings/wasm/notarization_wasm/README.md) - - [Notarization Examples](https://github.com/iotaledger/notarization/tree/main/bindings/wasm/notarization_wasm/examples/README.md) - - [IOTA Notarization Docs Portal](https://docs.iota.org/developer/iota-notarization) -- IOTA Audit Trail: - - [Audit Trail Rust SDK README](https://github.com/iotaledger/notarization/tree/main/audit-trail-rs/README.md) - - [Audit Trail Move Package README](https://github.com/iotaledger/notarization/tree/main/audit-trail-move/README.md) - - [Audit Trail Wasm README](https://github.com/iotaledger/notarization/tree/main/bindings/wasm/audit_trail_wasm/README.md) - - [Audit Trail Wasm Examples](https://github.com/iotaledger/notarization/tree/main/bindings/wasm/audit_trail_wasm/examples/README.md) -- Shared: - - [Repository Examples](https://github.com/iotaledger/notarization/tree/main/examples/README.md) +### IOTA Notarization + +- [Notarization Rust SDK README](./notarization-rs/README.md) +- [Notarization Move Package README](./notarization-move/README.md) +- [Notarization Wasm README](./bindings/wasm/notarization_wasm/README.md) +- [Notarization examples](./bindings/wasm/notarization_wasm/examples/README.md) +- [IOTA Notarization Docs Portal](https://docs.iota.org/developer/iota-notarization) + +### IOTA Audit Trail + +- [Audit Trail Rust SDK README](./audit-trail-rs/README.md) +- [Audit Trail Move Package README](./audit-trail-move/README.md) +- [Audit Trail Wasm README](./bindings/wasm/audit_trail_wasm/README.md) +- [Audit Trail examples](./bindings/wasm/audit_trail_wasm/examples/README.md) + +### Shared + +- [Repository examples](./examples/README.md) ## Bindings -[Foreign Function Interface (FFI)](https://en.wikipedia.org/wiki/Foreign_function_interface) bindings in this repository: +[Foreign Function Interface (FFI)](https://en.wikipedia.org/wiki/Foreign_function_interface) bindings available in this repository: -- [Web Assembly](https://github.com/iotaledger/notarization/tree/main/bindings/wasm/notarization_wasm) for IOTA Notarization -- [Web Assembly](https://github.com/iotaledger/notarization/tree/main/bindings/wasm/audit_trail_wasm) for IOTA Audit Trail +- [Web Assembly for IOTA Notarization](./bindings/wasm/notarization_wasm) +- [Web Assembly for IOTA Audit Trail](./bindings/wasm/audit_trail_wasm) ## Contributing diff --git a/audit-trail-rs/README.md b/audit-trail-rs/README.md index df8e25d2..f02ca754 100644 --- a/audit-trail-rs/README.md +++ b/audit-trail-rs/README.md @@ -9,7 +9,6 @@

IntroductionDocumentation & Resources ◈ - Feature OverviewBindingsContributing

@@ -45,43 +44,6 @@ The crate provides: This README is also used as the crate-level rustdoc entry point, while the source files provide detailed API documentation for all public types and methods. -## Feature Overview - -The public API is organized around a small set of entry points: - -- [`AuditTrailClientReadOnly`] for package resolution, trail-scoped reads, and inspected transactions -- [`AuditTrailClient`] for signed write flows -- [`AuditTrailHandle`] for operations scoped to one trail object -- [`AuditTrailBuilder`] for configuring trail creation -- [`core::types`] for domain types such as [`Data`], [`Record`], [`LockingConfig`], and [`PermissionSet`] - -Typical flow: - -1. Construct an [`AuditTrailClientReadOnly`] or [`AuditTrailClient`]. -2. Resolve a trail with [`AuditTrailClientReadOnly::trail`] or [`AuditTrailClient::trail`]. -3. Read state with [`AuditTrailHandle::get`] or move into one of the trail subsystems: - - [`AuditTrailHandle::records`] - - [`AuditTrailHandle::locking`] - - [`AuditTrailHandle::access`] - - [`AuditTrailHandle::tags`] -4. For writes, build a typed transaction from the client, trail handle, or subsystem handle and execute it through the surrounding transaction infrastructure. - -The crate deliberately separates transaction construction from submission so applications can keep signing, sponsorship, gas selection, and batching policy outside the SDK. - -Pure value types expose executable doctests where the behavior is self-contained and stable: - -```rust -use audit_trail::core::types::{Data, InitialRecord}; - -let record = InitialRecord::new(Data::text("hello"), Some("first write".to_string()), None); - -assert_eq!(record.data, Data::text("hello")); -assert_eq!(record.metadata.as_deref(), Some("first write")); -assert!(record.tag.is_none()); -``` - -If you are integrating against a custom deployment, use [`PackageOverrides`] during client construction so the crate does not rely on the built-in package registry for that environment. - ## Bindings [Foreign Function Interface (FFI)](https://en.wikipedia.org/wiki/Foreign_function_interface) bindings of this Rust SDK to other programming languages: diff --git a/audit-trail-rs/src/core/access/operations.rs b/audit-trail-rs/src/core/access/operations.rs index 0e57ea41..ff792349 100644 --- a/audit-trail-rs/src/core/access/operations.rs +++ b/audit-trail-rs/src/core/access/operations.rs @@ -1,6 +1,8 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +//! Internal access-control helpers that build role and capability transactions. + use iota_interaction::types::base_types::{IotaAddress, ObjectID}; use iota_interaction::types::transaction::{ObjectArg, ProgrammableTransaction}; use iota_interaction::{OptionalSync, ident_str}; @@ -10,6 +12,7 @@ use crate::core::internal::{trail as trail_reader, tx}; use crate::core::types::{CapabilityIssueOptions, Permission, PermissionSet, RoleTags}; use crate::error::Error; +/// Internal namespace for role and capability transaction construction. pub(super) struct AccessOps; impl AccessOps { @@ -287,6 +290,10 @@ impl AccessOps { } } +/// Verifies that every requested role tag already exists in the trail tag registry. +/// +/// Roles may only reference tags that are defined on the trail itself so later record-tag checks +/// stay consistent with the registry stored on-chain. async fn assert_role_tags_defined(client: &C, trail_id: ObjectID, role_tags: &Option) -> Result<(), Error> where C: CoreClientReadOnly + OptionalSync, diff --git a/audit-trail-rs/src/core/create/operations.rs b/audit-trail-rs/src/core/create/operations.rs index d330e0b1..30132c81 100644 --- a/audit-trail-rs/src/core/create/operations.rs +++ b/audit-trail-rs/src/core/create/operations.rs @@ -1,6 +1,8 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +//! Internal helpers that turn validated builder state into the trail-creation Move call. + use std::collections::HashSet; use iota_interaction::ident_str; @@ -12,20 +14,36 @@ use crate::core::internal::tx; use crate::core::types::{Data, ImmutableMetadata, InitialRecord, LockingConfig}; use crate::error::Error; +/// Internal namespace for trail-creation transaction construction. pub(super) struct CreateOps; +/// Normalized inputs required to build the `main::create` programmable transaction. +/// +/// This keeps the public builder layer separate from the low-level PTB encoding logic. pub(super) struct CreateTrailArgs { + /// Audit-trail package used for generic type tags and Move calls. pub audit_trail_package_id: ObjectID, + /// TfComponents package used by locking and capability-related values. pub tf_components_package_id: ObjectID, + /// Address that should receive the initial admin capability. pub admin: IotaAddress, + /// Optional first record inserted into the newly created trail. pub initial_record: Option, + /// Initial locking rules for the trail. pub locking_config: LockingConfig, + /// Immutable metadata stored at trail creation time. pub trail_metadata: Option, + /// Mutable metadata slot initialized together with the trail. pub updatable_metadata: Option, + /// Canonical set of record tags that may be used on the trail. pub record_tags: HashSet, } impl CreateOps { + /// Builds the programmable transaction that creates a new audit trail. + /// + /// Record tags are sorted before serialization so the resulting wire format is stable across + /// equivalent `HashSet` inputs. pub(super) fn create_trail(args: CreateTrailArgs) -> Result { let mut ptb = ProgrammableTransactionBuilder::new(); let CreateTrailArgs { diff --git a/audit-trail-rs/src/core/locking/operations.rs b/audit-trail-rs/src/core/locking/operations.rs index 9d1a9469..fd9142fe 100644 --- a/audit-trail-rs/src/core/locking/operations.rs +++ b/audit-trail-rs/src/core/locking/operations.rs @@ -1,6 +1,8 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +//! Internal helpers that build locking-related programmable transactions. + use iota_interaction::OptionalSync; use iota_interaction::types::base_types::{IotaAddress, ObjectID}; use iota_interaction::types::transaction::ProgrammableTransaction; @@ -10,6 +12,7 @@ use crate::core::internal::tx; use crate::core::types::{LockingConfig, LockingWindow, Permission, TimeLock}; use crate::error::Error; +/// Internal namespace for locking transaction construction. pub(super) struct LockingOps; impl LockingOps { diff --git a/audit-trail-rs/src/core/mod.rs b/audit-trail-rs/src/core/mod.rs index c5b3db15..d4412d3a 100644 --- a/audit-trail-rs/src/core/mod.rs +++ b/audit-trail-rs/src/core/mod.rs @@ -5,14 +5,14 @@ //! //! The modules in this namespace make up the main domain-facing API: //! -//! - [`access`] exposes role and capability management -//! - [`builder`] configures trail creation -//! - [`create`] contains the creation transaction types -//! - [`locking`] manages trail locking rules -//! - [`records`] reads and mutates trail records -//! - [`tags`] manages the trail-owned record-tag registry -//! - [`trail`] provides the high-level typed handle bound to a specific trail -//! - [`types`] contains serializable value types shared across the crate +//! - [`crate::core::access`] exposes role and capability management +//! - [`crate::core::builder`] configures trail creation +//! - [`crate::core::create`] contains the creation transaction types +//! - [`crate::core::locking`] manages trail locking rules +//! - [`crate::core::records`] reads and mutates trail records +//! - [`crate::core::tags`] manages the trail-owned record-tag registry +//! - [`crate::core::trail`] provides the high-level typed handle bound to a specific trail +//! - [`crate::core::types`] contains serializable value types shared across the crate /// Role and capability management APIs. pub mod access; diff --git a/audit-trail-rs/src/core/records/operations.rs b/audit-trail-rs/src/core/records/operations.rs index 84df9a9c..0a8ded89 100644 --- a/audit-trail-rs/src/core/records/operations.rs +++ b/audit-trail-rs/src/core/records/operations.rs @@ -1,6 +1,8 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +//! Internal record-operation helpers that build trail-scoped programmable transactions. + use iota_interaction::OptionalSync; use iota_interaction::types::base_types::{IotaAddress, ObjectID}; use iota_interaction::types::transaction::ProgrammableTransaction; @@ -10,6 +12,7 @@ use crate::core::internal::{capability, trail as trail_reader, tx}; use crate::core::types::{Data, OnChainAuditTrail, Permission}; use crate::error::Error; +/// Internal namespace for record-related transaction construction. pub(super) struct RecordsOps; impl RecordsOps { @@ -136,6 +139,10 @@ impl RecordsOps { } } +/// Finds an `AddRecord` capability that is also allowed to write records with `tag`. +/// +/// Tagged record writes require both the base `AddRecord` permission and a role whose associated +/// record-tag policy explicitly allows the requested tag. async fn find_capable_cap_for_tag( client: &C, owner: IotaAddress, diff --git a/audit-trail-rs/src/core/tags/operations.rs b/audit-trail-rs/src/core/tags/operations.rs index 57b8b7a3..b86b5499 100644 --- a/audit-trail-rs/src/core/tags/operations.rs +++ b/audit-trail-rs/src/core/tags/operations.rs @@ -1,6 +1,8 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +//! Internal helpers that build record-tag registry transactions. + use iota_interaction::OptionalSync; use iota_interaction::types::base_types::{IotaAddress, ObjectID}; use iota_interaction::types::transaction::ProgrammableTransaction; @@ -10,6 +12,7 @@ use crate::core::internal::tx; use crate::core::types::Permission; use crate::error::Error; +/// Internal namespace for tag-registry transaction construction. pub(super) struct TagsOps; impl TagsOps { diff --git a/audit-trail-rs/src/core/trail/operations.rs b/audit-trail-rs/src/core/trail/operations.rs index 88db6049..975c09ce 100644 --- a/audit-trail-rs/src/core/trail/operations.rs +++ b/audit-trail-rs/src/core/trail/operations.rs @@ -1,6 +1,8 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +//! Internal helpers that build trail-level programmable transactions. + use iota_interaction::OptionalSync; use iota_interaction::types::base_types::{IotaAddress, ObjectID}; use iota_interaction::types::transaction::ProgrammableTransaction; @@ -10,6 +12,7 @@ use crate::core::internal::tx; use crate::core::types::Permission; use crate::error::Error; +/// Internal namespace for trail-level transaction construction. pub(super) struct TrailOps; impl TrailOps { diff --git a/audit-trail-rs/src/iota_interaction_adapter.rs b/audit-trail-rs/src/iota_interaction_adapter.rs index ec0d9f0a..c2db171b 100644 --- a/audit-trail-rs/src/iota_interaction_adapter.rs +++ b/audit-trail-rs/src/iota_interaction_adapter.rs @@ -1,9 +1,10 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -// The following platform compile switch provides all the -// ...Adapter types from iota_interaction_rust or iota_interaction_ts -// like IotaClientAdapter, TransactionBuilderAdapter ... and so on +//! Platform-dependent adapter re-exports for the underlying IOTA interaction layer. +//! +//! This keeps the rest of the crate generic over native and wasm targets by exposing the same +//! adapter names from either `iota_interaction_rust` or `iota_interaction_ts`. #[cfg(not(target_arch = "wasm32"))] pub(crate) use iota_interaction_rust::*; diff --git a/notarization-rs/README.md b/notarization-rs/README.md index e7faf0a9..75be16ba 100644 --- a/notarization-rs/README.md +++ b/notarization-rs/README.md @@ -6,37 +6,6 @@ instance, which is mapped to the Notarization object on the ledger and can be us You can find the full IOTA Notarization documentation [here](https://docs.iota.org/developer/iota-notarization). -Following Notarization methods are currently provided: - -- Dynamic Notarization -- Locked Notarization - -These Notarization methods are implemented using a single Notarization Move object, stored on the IOTA Ledger. -The Method specific behavior is achieved via configuration of this object. - -To minimize the need for config settings, the Notarization methods reduce the number of available configuration -parameters while using method specific fixed settings for several parameters, resulting in the typical method -specific behaviour. Here, Notarization methods can be seen as prepared configuration sets to facilitate -Notarization usage for often needed use cases. - -Here is an overview of the most important configuration parameters for each of these methods: - -| Method | Locking exists | delete_lock* | update_lock | transfer_lock | -| ------- | --------------- | ---------------- | ----------------------- | ----------------------- | -| Dynamic | Optional [conf] | None [static] | None [static] | Optional [conf] | -| Locked | Yes [static] | Optional* [conf] | UntilDestroyed [static] | UntilDestroyed [static] | - -Explanation of terms and symbols for the table above: - -- [conf]: Configurable parameter. -- [static]: Fixed or static parameter. -- Optional: - - Locks: The lock can be set to UnlockAt or UntilDestroyed. - - Locking exists: If no locking is used, there will be no [`LockMetadata`] stored with the Notarization object - Otherwise [`LockMetadata`] will be created automatically. If no [`LockMetadata`] exist, the behaviour is - equivalent to existing [`LockMetadata`] with all locks set to [`None`]. - - *: delete_lock can not be set to `UntilDestroyed`. - ## Process Flows The following workflows demonstrate how NotarizationBuilder and Notarization instances can be used to create, update and diff --git a/notarization-rs/src/lib.rs b/notarization-rs/src/lib.rs index 611fae81..25512ddd 100644 --- a/notarization-rs/src/lib.rs +++ b/notarization-rs/src/lib.rs @@ -1,6 +1,8 @@ // Copyright 2020-2025 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +#![doc = include_str!("../README.md")] + pub mod client; pub mod core; pub mod error; From a32c13dc431aeeba814980cb092a37874f92ade1 Mon Sep 17 00:00:00 2001 From: chrisgitiota Date: Thu, 2 Apr 2026 16:08:24 +0200 Subject: [PATCH 129/189] Initial AT example and enhanced docs --- CLAUDE.md | 82 ++++++- audit-trail-move/Move.lock | 10 +- audit-trail-rs/src/client/full_client.rs | 4 +- audit-trail-rs/src/core/types/audit_trail.rs | 44 ++++ audit-trail-rs/src/core/types/role_map.rs | 215 +++++++++++++++++- examples/Cargo.toml | 5 + examples/audit-trail/01_create_audit_trail.rs | 112 +++++++++ examples/audit-trail/README.md | 147 ++++++++++++ examples/audit-trail/run.sh | 37 +++ .../01_create_locked_notarization.rs | 4 +- .../02_create_dynamic_notarization.rs | 4 +- .../03_update_dynamic_notarization.rs | 4 +- .../notarization/04_destroy_notarization.rs | 4 +- examples/notarization/05_update_state.rs | 4 +- examples/notarization/06_update_metadata.rs | 4 +- .../07_transfer_dynamic_notarization.rs | 4 +- .../08_access_read_only_methods.rs | 4 +- examples/{ => notarization}/README.md | 0 .../real-world/01_iot_weather_station.rs | 4 +- .../real-world/02_legal_contract.rs | 4 +- examples/notarization/run.sh | 42 ++++ examples/run.sh | 49 +--- examples/utils/utils.rs | 64 +++++- 23 files changed, 762 insertions(+), 89 deletions(-) create mode 100644 examples/audit-trail/01_create_audit_trail.rs create mode 100644 examples/audit-trail/README.md create mode 100755 examples/audit-trail/run.sh rename examples/{ => notarization}/README.md (100%) create mode 100755 examples/notarization/run.sh diff --git a/CLAUDE.md b/CLAUDE.md index dab30dc2..18c8a6f9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,7 +50,9 @@ npm run test:browser # Cypress browser tests ``` ### Running Examples -Examples require the notarization package to be published first. From the repo root: +Examples require the relevant Move package to be published first. + +**Notarization examples** — from the repo root: ```bash # Publish the package and capture the package ID export IOTA_NOTARIZATION_PKG_ID=$(./notarization-move/scripts/publish_package.sh) @@ -58,12 +60,83 @@ export IOTA_NOTARIZATION_PKG_ID=$(./notarization-move/scripts/publish_package.sh # Run a specific example cargo run --release --example ``` -To run all examples. From the repo root:: +To run all notarization examples: ```bash # Make sure IOTA_NOTARIZATION_PKG_ID is set as shown above -./examples/run.sh +./examples/run.sh +``` + +**Audit Trail examples** — from the repo root: +```bash +# Publish the package; on localnet both vars are set to the same package ID +eval $(./audit-trail-move/scripts/publish_package.sh) + +# Run a specific example +cargo run --release --example ``` +The `eval` form is required because the publish script prints shell `export` statements for two variables: +- `IOTA_AUDIT_TRAIL_PKG_ID` — the audit trail package ID +- `IOTA_TF_COMPONENTS_PKG_ID` — the TfComponents package ID (equals `IOTA_AUDIT_TRAIL_PKG_ID` on localnet) + +## Developing Examples + +### Adding a new example +1. Create the source file under `examples/notarization/` or `examples/audit-trail/`. +2. Add an `[[example]]` entry to `examples/Cargo.toml` pointing to the new file. +3. Use `examples::get_funded_notarization_client()` (notarization) or `examples::get_funded_audit_trail_client()` (audit trail) from `examples/utils/utils.rs` to obtain a funded, signed client. Do not inline client construction in example files. + +### Audit Trail example patterns +Reference implementation: `examples/audit-trail/01_create_audit_trail.rs` + +**Client setup** — `get_funded_audit_trail_client()` reads `IOTA_AUDIT_TRAIL_PKG_ID` and `IOTA_TF_COMPONENTS_PKG_ID` from the environment and returns `AuditTrailClient`. + +**Creating a trail** — use the builder returned by `client.create_trail()`: +```rust +let created = client + .create_trail() + .with_trail_metadata(ImmutableMetadata::new("name".into(), Some("description".into()))) + .with_updatable_metadata("mutable status string") + .with_initial_record(InitialRecord::new(Data::text("content"), Some("metadata".into()), None)) + .finish() + .build_and_execute(&client) + .await? + .output; // TrailCreated { trail_id, creator, timestamp } +``` +The creator automatically receives an Admin capability object in their wallet. + +**Defining a role** — use the trail handle's access API with the implicit Admin capability: +```rust +client + .trail(trail_id) + .access() + .for_role("RecordAdmin") + .create(PermissionSet::record_admin_permissions(), None) + .build_and_execute(&client) + .await?; +``` +`PermissionSet` convenience constructors: `admin_permissions()`, `record_admin_permissions()`, `locking_admin_permissions()`, `tag_admin_permissions()`, `cap_admin_permissions()`, `metadata_admin_permissions()`. + +**Issuing a capability** — mint a capability object for a role: +```rust +let cap = client + .trail(trail_id) + .access() + .for_role("RecordAdmin") + .issue_capability(CapabilityIssueOptions::default()) + .build_and_execute(&client) + .await? + .output; // CapabilityIssued { capability_id, target_key, role, issued_to, valid_from, valid_until } +``` +Use `CapabilityIssueOptions { issued_to, valid_from_ms, valid_until_ms }` to restrict who may use the capability or set a validity window. + +**Key types** (from `audit_trail::core::types`): `Data`, `InitialRecord`, `ImmutableMetadata`, `LockingConfig`, `LockingWindow`, `TimeLock`, `Permission`, `PermissionSet`, `CapabilityIssueOptions`, `RoleTags`. + +### Notarization example patterns +Reference implementations: `examples/notarization/01_create_locked_notarization.rs` and `examples/notarization/02_create_dynamic_notarization.rs`. + +Use `examples::get_funded_notarization_client()` to get a `NotarizationClient`. Read `audit-trail-rs/tests/e2e/` for detailed usage of every API surface. + ## Workspace Structure The root `Cargo.toml` defines a workspace with members: `notarization-rs`, `audit-trail-rs`, `examples`. The WASM crates (`bindings/wasm/*`) are excluded from the workspace and built separately. @@ -108,7 +181,8 @@ Code uses `#[cfg(target_arch = "wasm32")]` guards to conditionally compile for W - Tests require an IOTA sandbox running locally - Always use `--test-threads=1` (tests share sandbox state) -- Examples require `IOTA_NOTARIZATION_PKG_ID` environment variable set to the deployed package ID +- Notarization examples require `IOTA_NOTARIZATION_PKG_ID` environment variable set to the deployed package ID +- Audit trail examples require `IOTA_AUDIT_TRAIL_PKG_ID` (and `IOTA_TF_COMPONENTS_PKG_ID` on localnet) — use `eval $(./audit-trail-move/scripts/publish_package.sh)` to set both - WASM browser tests use Cypress ## Rust Version diff --git a/audit-trail-move/Move.lock b/audit-trail-move/Move.lock index e40c12e9..7715fcc0 100644 --- a/audit-trail-move/Move.lock +++ b/audit-trail-move/Move.lock @@ -2,7 +2,7 @@ [move] version = 3 -manifest_digest = "0CE297EF7E5DDA3F07E2E03FFCE0F19FA61914E440324D82CBC4E431B1FD600D" +manifest_digest = "EBCB35B368C39FD9E190F502DC6A07A51CF87960E25EC4439DCBF9FBA8307B3C" deps_digest = "397E6A9F7A624706DBDFEE056CE88391A15876868FD18A88504DA74EB458D697" dependencies = [ { id = "Iota", name = "Iota" }, @@ -44,7 +44,7 @@ dependencies = [ [[move.package]] id = "TfComponents" -source = { local = "../../product-core/components_move" } +source = { git = "https://github.com/iotaledger/product-core.git", rev = "feat/tf-compoenents-dev", subdir = "components_move" } dependencies = [ { id = "Iota", name = "Iota" }, @@ -61,9 +61,9 @@ flavor = "iota" [env] [env.localnet] -chain-id = "39f6312e" -original-published-id = "0x148830e6d46618708fcc5065ff2ce1077fe45997cd0bae3f489c9b1ee5571f48" -latest-published-id = "0x148830e6d46618708fcc5065ff2ce1077fe45997cd0bae3f489c9b1ee5571f48" +chain-id = "fbd38c7a" +original-published-id = "0xf5318c775c59c55c99cd69f94c6b88e62dca9ea93b20fbb54d2a7728708a719f" +latest-published-id = "0xf5318c775c59c55c99cd69f94c6b88e62dca9ea93b20fbb54d2a7728708a719f" published-version = "1" [env.testnet] diff --git a/audit-trail-rs/src/client/full_client.rs b/audit-trail-rs/src/client/full_client.rs index 2d2a61b3..cf8f1d2d 100644 --- a/audit-trail-rs/src/client/full_client.rs +++ b/audit-trail-rs/src/client/full_client.rs @@ -32,10 +32,10 @@ use crate::iota_interaction_adapter::IotaClientAdapter; #[non_exhaustive] pub struct NoSigner; -/// The error that results from a failed attempt at creating an [IdentityClient] +/// The error that results from a failed attempt at creating an [AuditTrailClient] /// from a given [IotaClient]. #[derive(Debug, thiserror::Error)] -#[error("failed to create an 'IdentityClient' from the given 'IotaClient'")] +#[error("failed to create an 'AuditTrailClient' from the given 'IotaClient'")] #[non_exhaustive] pub struct FromIotaClientError { /// Type of failure for this error. diff --git a/audit-trail-rs/src/core/types/audit_trail.rs b/audit-trail-rs/src/core/types/audit_trail.rs index 06d62392..2cc95e50 100644 --- a/audit-trail-rs/src/core/types/audit_trail.rs +++ b/audit-trail-rs/src/core/types/audit_trail.rs @@ -19,29 +19,46 @@ use crate::core::internal::move_collections::deserialize_vec_map; use crate::core::internal::tx; use crate::error::Error; +/// Registry of record tags configured for an audit trail. +/// +/// Each entry maps a tag name to its current usage count across role definitions and records. +/// +/// `TagRegistry` maintains a combined usage count per tag that is +/// incremented every time a record or role references the tag. A tag cannot be +/// removed from the registry while its usage count is greater than zero. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct TagRegistry { + /// Mapping from a human-readable tag name to how many times it is used in role definitions and records. #[serde(deserialize_with = "deserialize_vec_map")] pub tag_map: HashMap, } impl TagRegistry { + /// Returns the number of tags currently registered. pub fn len(&self) -> usize { self.tag_map.len() } + /// Returns `true` if the registry contains no tags. pub fn is_empty(&self) -> bool { self.tag_map.is_empty() } + /// Returns `true` if a tag with the given name exists in the registry. + /// + /// - `tag`: The tag name to look up. pub fn contains_key(&self, tag: &str) -> bool { self.tag_map.contains_key(tag) } + /// Returns the current usage count associated with a tag name. + /// + /// - `tag`: The tag name to look up. pub fn get(&self, tag: &str) -> Option<&u64> { self.tag_map.get(tag) } + /// Iterates over all registered tag names and their usage counts. pub fn iter(&self) -> impl Iterator { self.tag_map.iter() } @@ -50,31 +67,55 @@ impl TagRegistry { /// An audit trail stored on-chain. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct OnChainAuditTrail { + /// Unique object id of the audit trail. pub id: UID, + /// Address that originally created the trail. pub creator: IotaAddress, + /// Unix timestamp in milliseconds when the trail was created. pub created_at: u64, + /// Monotonically increasing number. + /// Can be interpreted as total number of created `Record` instances by this audit trai. + /// Will be used as identifier for the next record added to the trail, starting at 0 for the first record. pub sequence_number: u64, + /// Contains the trail's records keyed by `sequence_number`. pub records: LinkedTable, + /// Registry of tag names tracked with their current usage counts. + /// Tag names can be added to records to restrict record-access to users having capabilities + /// granting access to this tag. Tag specific access can be defined by adding tags to [`Role`] definitions. pub tags: TagRegistry, + /// Active write/delete locking rules for this trail. pub locking_config: LockingConfig, + /// [`Role`] definitions and permissions configured for the trail. pub roles: RoleMap, + /// Immutable metadata set at creation time, if present. pub immutable_metadata: Option, + /// Mutable metadata string that can be updated after creation, if present. pub updatable_metadata: Option, + /// On-chain schema or object version maintained by the Move package. pub version: u64, } /// Metadata set at trail creation and never updated. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ImmutableMetadata { + /// Human-readable trail name. pub name: String, + /// Optional longer description explaining the trail's purpose. pub description: Option, } impl ImmutableMetadata { + /// Creates immutable metadata for a new trail. + /// + /// - `name`: The human-readable name to store on the trail. + /// - `description`: An optional longer description stored alongside the name. pub fn new(name: String, description: Option) -> Self { Self { name, description } } + /// Returns the Move type tag for `main::ImmutableMetadata` in the given package. + /// + /// - `package_id`: The published audit-trail Move package id. pub(in crate::core) fn tag(package_id: ObjectID) -> TypeTag { TypeTag::from_str(&format!("{package_id}::main::ImmutableMetadata")) .expect("invalid TypeTag for ImmutableMetadata") @@ -83,6 +124,9 @@ impl ImmutableMetadata { /// Creates a new `Argument` from the `ImmutableMetadata`. /// /// To be used when creating a new `ImmutableMetadata` object on the ledger. + /// + /// - `ptb`: The programmable transaction builder the argument should be added to. + /// - `package_id`: The published audit-trail Move package id. pub(in crate::core) fn to_ptb(&self, ptb: &mut Ptb, package_id: ObjectID) -> Result { let name = tx::ptb_pure(ptb, "name", &self.name)?; let description = tx::ptb_pure(ptb, "description", &self.description)?; diff --git a/audit-trail-rs/src/core/types/role_map.rs b/audit-trail-rs/src/core/types/role_map.rs index a0b241fe..ff19f4de 100644 --- a/audit-trail-rs/src/core/types/role_map.rs +++ b/audit-trail-rs/src/core/types/role_map.rs @@ -17,60 +17,231 @@ use super::permission::Permission; use crate::core::internal::move_collections::{deserialize_vec_map, deserialize_vec_set}; use crate::core::internal::tx; use crate::error::Error; +/// The role and capability registry attached to an audit trail. +/// +/// A [`RoleMap`] stores every named role defined on the trail, tracks which +/// capabilities have been revoked, and records the administrative permission +/// requirements for role and capability management. +/// +/// ## Roles and capabilities +/// +/// Each entry in [`roles`](RoleMap::roles) maps a role name to a [`Role`] that +/// holds a set of [`Permission`]s and [`RoleTags`]. +/// Each [`Capability`] is associated with exactly one [`Role`] and belongs to a specific [`AuditTrail`] +/// instance which is identified by the [`target_key`](RoleMap::target_key). +/// +/// ## What are Roles +/// +/// A role is a named set of [`Permission`]s, optionally paired with a [`RoleTags`] allowlist. +/// +/// Roles are identified by a unique string name within a trail (e.g., `"RecordAdmin"`, +/// `"Auditor"`, `"LegalReviewer"`). The same role definition can back many independent +/// [`Capability`] objects — to be owned and used by users or system components that should share the same +/// access level. A capability holder may exercise only the permissions of the role it was +/// issued for. +/// +/// ## Initial admin role and capability +/// +/// When a trail is created the Move runtime mints an *initial admin* +/// capability and transfers it to the creator (or the address supplied via +/// `with_admin`). +/// +/// The *initial admin* role name is indicated by [`initial_admin_role_name`]. +/// The role grants permissions specified by [`role_admin_permissions`] and [`capability_admin_permissions`], +/// which are required to manage additional roles and capabilities. +/// +/// ## Create, Delete and Update Roles +/// +/// All three operations are gated by the permissions stored in +/// [`role_admin_permissions`](RoleMap::role_admin_permissions): +/// +/// | Operation | Required permission | Additional constraints | +/// |-----------|--------------------------------------|-----------------------------------------------------------------------------------------------------------------| +/// | Create | `role_admin_permissions.add` | Any [`RoleTags`] specified must be registered in the trail's tag registry. | +/// | Delete | `role_admin_permissions.delete` | The initial admin role (see [`initial_admin_role_name`](RoleMap::initial_admin_role_name)) cannot be deleted. | +/// | Update | `role_admin_permissions.update` | Updating the initial admin role requires the new permission set to still include all configured admin permissions.| +/// +/// The caller supplies a [`Capability`] that is validated before the operation proceeds. +/// An `ECapabilityPermissionDenied` error is returned if the capability's role does not +/// carry the required permission. +/// +/// ## Issue, Revoke, and Destroy Capabilities +/// +/// **Issuing** a capability requires the `capability_admin_permissions.add` permission. +/// [`CapabilityIssueOptions`] allow restricting a newly minted capability further: +/// - `issued_to` — binds the capability to a specific wallet address; the Move runtime +/// rejects use by any other sender. +/// - `valid_from_ms` / `valid_until_ms` — a Unix-millisecond validity window; use outside +/// this range is rejected. +/// +/// **Revoking** a capability requires the `capability_admin_permissions.revoke` permission. +/// Revocation adds the capability's ID to the [`revoked_capabilities`](RoleMap::revoked_capabilities) +/// denylist; the object itself continues to exist on-chain but is refused by +/// `assert_capability_valid`. The caller must provide: +/// - the capability's object ID, and +/// - optionally its `valid_until` value, which allows the denylist entry to be cleaned up +/// automatically once it expires via [`AuditTrailHandle::access().cleanup_revoked_capabilities`]. +/// +/// Because the `RoleMap` uses a denylist (not an allowlist), it does **not** track all +/// issued capabilities on-chain. Callers are responsible for maintaining an off-chain +/// record of issued capability IDs and their validity constraints so that the correct ID +/// can be supplied at revocation time. +/// +/// **Destroying** a capability permanently removes it from the chain. Any holder may +/// destroy their own capability without needing any admin permission — this is intentional +/// so that users can always clean up capabilities they no longer need. Destroying a +/// revoked capability also removes it from the denylist. +/// +/// ## Managing the initial admin role and its capabilities +/// +/// The initial admin role is the only role that exists when a trail is first created. +/// It carries all permissions required to manage roles and capabilities +/// (i.e. everything in [`role_admin_permissions`](RoleMap::role_admin_permissions) and +/// [`capability_admin_permissions`](RoleMap::capability_admin_permissions)). +/// +/// Two invariants protect it from accidental lock-out: +/// - The initial admin **role** can never be deleted. +/// - Updating its permissions is only permitted if the new permission set still includes all +/// configured role and capability admin permissions. +/// +/// Initial admin **capabilities** are tracked separately in +/// [`initial_admin_cap_ids`](RoleMap::initial_admin_cap_ids) and must be managed through +/// dedicated entry-points: +/// - `revoke_initial_admin_capability` — adds the cap to the denylist. +/// - `destroy_initial_admin_capability` — permanently removes the cap from the chain. +/// +/// Attempting to use the generic `revoke_capability` or `destroy_capability` on an initial +/// admin capability returns `EInitialAdminCapabilityMustBeExplicitlyDestroyed`. +/// +/// ## Using Tags +/// Tags are string labels managed by the audit trail using a [`TagRegistry`](super::audit_trail::TagRegistry). +/// The registry acts as a controlled vocabulary: a tag must be registered on the +/// trail before it can be attached to a record or referenced by a role. +/// +/// Each record may carry at most one immutable tag. Tagged Records can only be accessed +/// by users having Capabilities with Roles that allow the tag in their RoleTags. +/// This allows for flexible access control policies based on record tags, +/// i.e. to allow access to specific records only for users in specific departments. +/// +/// Each role may optionally include a [`RoleTags`] allowlist that grants the holders of that +/// role's capability access to records tagged with that specific tag. +/// +/// #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RoleMap { + /// The object ID of the audit trail this role map belongs to. pub target_key: ObjectID, + /// All named roles defined on the trail, keyed by role name. #[serde(deserialize_with = "deserialize_vec_map")] pub roles: HashMap, + /// Name of the built-in admin role created automatically at trail creation + /// (typically `"Admin"`). pub initial_admin_role_name: String, + /// Set of capability IDs that have been revoked and must no longer be + /// accepted by the Move runtime. pub revoked_capabilities: LinkedTable, + /// Object IDs of the initial admin capabilities minted at trail creation. + /// These require dedicated revoke/destroy entry-points. #[serde(deserialize_with = "deserialize_vec_set")] pub initial_admin_cap_ids: HashSet, + /// Permissions required to add, update, and delete roles on this trail. pub role_admin_permissions: RoleAdminPermissions, + /// Permissions required to issue and revoke capabilities on this trail. pub capability_admin_permissions: CapabilityAdminPermissions, } +/// A single role definition within a [`RoleMap`]. +/// +/// Each role combines a permission set that governs what operations holders may +/// perform, and optional [`RoleTags`] data that restricts which tagged records +/// those holders may interact with. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Role { + /// The set of [`Permission`]s granted to any [`Capability`] issued for this role. #[serde(deserialize_with = "deserialize_vec_set")] pub permissions: HashSet, + /// Optional tag allowlist. When present, a capability holder for this role may only + /// add or access records whose tag is contained in this set. When `None`, the role + /// does not impose any tag-based restriction (but untagged-record permissions still + /// apply). pub data: Option, } -/// Defines the permissions required to administer roles in this RoleMap. +/// Defines the permissions required to administer roles in this [`RoleMap`]. +/// +/// When a capability holder attempts to create, delete, or update a role, the +/// `RoleMap` checks that the holder's role includes the corresponding permission +/// listed here. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RoleAdminPermissions { + /// The [`Permission`] a capability must carry to create a new role + /// (typically [`Permission::AddRoles`]). pub add: Permission, + /// The [`Permission`] a capability must carry to delete an existing role + /// (typically [`Permission::DeleteRoles`]). pub delete: Permission, + /// The [`Permission`] a capability must carry to update the permissions or + /// tags of an existing role (typically [`Permission::UpdateRoles`]). pub update: Permission, } -/// Defines the permissions required to administer capabilities in this RoleMap. +/// Defines the permissions required to administer capabilities in this [`RoleMap`]. +/// +/// When a capability holder attempts to issue or revoke a capability, the +/// `RoleMap` checks that the holder's role includes the corresponding permission +/// listed here. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CapabilityAdminPermissions { + /// The [`Permission`] a capability must carry to issue (mint) a new capability + /// (typically [`Permission::AddCapabilities`]). pub add: Permission, + /// The [`Permission`] a capability must carry to revoke an existing capability + /// or to clean up the revoked-capabilities denylist + /// (typically [`Permission::RevokeCapabilities`]). pub revoke: Permission, } -/// Capability issuance options used by the role-based API. +/// Options for constraining a newly issued [`Capability`]. +/// +/// All fields default to `None` (no restriction). Use [`Default::default()`] +/// to issue an unrestricted capability, or populate individual fields to add +/// constraints. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct CapabilityIssueOptions { + /// If set, only the specified address may present the capability. The Move + /// runtime rejects any transaction from a different sender with + /// `ECapabilityIssuedToMismatch`. pub issued_to: Option, + /// If set, the capability is not valid before this Unix timestamp + /// (milliseconds since epoch). Transactions submitted before this time + /// are rejected with `ECapabilityTimeConstraintsNotMet`. pub valid_from_ms: Option, + /// If set, the capability expires after this Unix timestamp (milliseconds + /// since epoch). Transactions submitted after this time are rejected with + /// `ECapabilityTimeConstraintsNotMet`. pub valid_until_ms: Option, } -/// Allowlisted record tags stored as role data on the Move side. +/// An allowlist of record tag names that may be attached to a [`Role`]. /// -/// The Rust name stays `RecordTags` for API continuity, but it maps to the -/// Move `record_tags::RoleTags` type. +/// When a role carries a `RoleTags` value, capability holders for that role may +/// only add or interact with records whose tag is contained in [`tags`](RoleTags::tags). +/// This maps to the Move `record_tags::RoleTags` type. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] pub struct RoleTags { + /// The set of tag names this role is allowed to use. Every tag listed here + /// must be registered in the trail's tag registry before the role is created + /// or updated. #[serde(deserialize_with = "deserialize_vec_set")] pub tags: HashSet, } impl RoleTags { + /// Creates a new [`RoleTags`] from any iterator of string-like items. + /// + /// # Arguments + /// + /// * `tags` — an iterator of tag names (e.g., `["finance", "legal"]`). pub fn new(tags: I) -> Self where I: IntoIterator, @@ -81,6 +252,11 @@ impl RoleTags { } } + /// Returns `true` if `tag` is present in this allowlist. + /// + /// # Arguments + /// + /// * `tag` — the record tag name to check. pub fn allows(&self, tag: &str) -> bool { self.tags.contains(tag) } @@ -104,22 +280,47 @@ impl RoleTags { } } -/// Capability data returned by the Move capability module. +/// An on-chain capability object deserialized from the Move `capability::Capability` type. +/// +/// A capability grants its holder the permissions of the [`Role`] identified by +/// [`role`](Capability::role) on the trail identified by +/// [`target_key`](Capability::target_key). The `RoleMap` validates all fields +/// of the capability before allowing any operation to proceed. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Capability { + /// The unique on-chain object ID of this capability. pub id: UID, + /// The object ID of the audit trail this capability is valid for. + /// Must match the [`RoleMap::target_key`] of the trail being accessed. pub target_key: ObjectID, + /// The name of the role this capability was issued for (e.g., `"Admin"`, + /// `"RecordAdmin"`). Determines the set of [`Permission`]s the holder may + /// exercise. pub role: String, + /// Optional address binding. When set, only the specified address may + /// present this capability; any other sender is rejected. pub issued_to: Option, + /// Optional start of the validity window (Unix milliseconds). The + /// capability is rejected before this timestamp. pub valid_from: Option, + /// Optional end of the validity window (Unix milliseconds). The capability + /// is rejected after this timestamp. pub valid_until: Option, } impl Capability { + /// Returns the Move `TypeTag` for `capability::Capability` in the given package. pub(crate) fn type_tag(package_id: ObjectID) -> TypeTag { TypeTag::from_str(format!("{package_id}::capability::Capability").as_str()).expect("failed to create type tag") } + /// Returns `true` if this capability targets the given trail and its role is + /// contained in `valid_roles`. + /// + /// # Arguments + /// + /// * `trail_id` — the object ID of the trail to match against. + /// * `valid_roles` — the set of role names considered acceptable. pub(crate) fn matches_target_and_role(&self, trail_id: ObjectID, valid_roles: &HashSet) -> bool { self.target_key == trail_id && valid_roles.contains(&self.role) } diff --git a/examples/Cargo.toml b/examples/Cargo.toml index 63cb96ad..c0595f62 100644 --- a/examples/Cargo.toml +++ b/examples/Cargo.toml @@ -48,8 +48,13 @@ path = "notarization/real-world/01_iot_weather_station.rs" name = "02_legal_contract" path = "notarization/real-world/02_legal_contract.rs" +[[example]] +name = "01_create_audit_trail" +path = "audit-trail/01_create_audit_trail.rs" + [dependencies] anyhow.workspace = true +audit_trail = { path = "../audit-trail-rs" } chrono = { workspace = true } iota-sdk = { workspace = true } iota_interaction = { workspace = true } diff --git a/examples/audit-trail/01_create_audit_trail.rs b/examples/audit-trail/01_create_audit_trail.rs new file mode 100644 index 00000000..a35a295b --- /dev/null +++ b/examples/audit-trail/01_create_audit_trail.rs @@ -0,0 +1,112 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use anyhow::Result; +use audit_trail::core::types::{CapabilityIssueOptions, Data, ImmutableMetadata, InitialRecord, PermissionSet}; +use examples::get_funded_audit_trail_client; +use product_common::core_client::CoreClient; + +/// Demonstrates how to: +/// 1. Create an audit trail with an initial record and metadata. +/// 2. Inspect the built-in Admin role that is automatically granted to the creator. +/// 3. Use the Admin capability to define a `RecordAdmin` role. +/// 4. Issue a capability for the `RecordAdmin` role. +#[tokio::main] +async fn main() -> Result<()> { + println!("=== Audit Trail: Create Trail & Define Roles ===\n"); + + // Create a funded client. The client's sender address becomes the initial Admin + // of any trail it creates. + let client = get_funded_audit_trail_client().await?; + println!("Client address: {}", client.sender_address()); + + // ------------------------------------------------------------------------- + // Step 1: Create an audit trail + // ------------------------------------------------------------------------- + // The builder supports optional immutable metadata (name + description), + // mutable updatable metadata, an initial record, record tag registry, and + // locking configuration. + // + // On success, the transaction engine automatically mints an Admin capability + // object and transfers it to the sender's address. This capability grants + // full administrative control over the trail (role management, capability + // issuance, tag management, etc.). + let created = client + .create_trail() + .with_trail_metadata(ImmutableMetadata::new( + "Product Shipment Audit Trail".to_string(), + Some("Immutable audit log for product lifecycle events".to_string()), + )) + .with_updatable_metadata("Status: Active") + .with_initial_record(InitialRecord::new( + Data::text("Shipment #SHP-20260401-001 created at warehouse A"), + Some("event:shipment_created;location:warehouse-a".to_string()), + None, + )) + .finish() + .build_and_execute(&client) + .await? + .output; + + println!( + "Trail created!\n Trail ID: {}\n Creator: {}\n Timestamp: {} ms\n", + created.trail_id, created.creator, created.timestamp + ); + + // Fetch the on-chain trail object to inspect the automatically created Admin role. + let trail = client.trail(created.trail_id).get().await?; + let admin_role_name = &trail.roles.initial_admin_role_name; + let admin_permissions = &trail.roles.roles[admin_role_name].permissions; + println!( + "Built-in admin role: \"{admin_role_name}\" ({} permissions)\n", + admin_permissions.len() + ); + + // ------------------------------------------------------------------------- + // Step 2: Define a RecordAdmin role + // ------------------------------------------------------------------------- + // The Admin capability (held by the sender) allows creating new roles. + // PermissionSet::record_admin_permissions() grants AddRecord, DeleteRecord, + // and CorrectRecord permissions. + let record_admin_role = "RecordAdmin"; + let role_created = client + .trail(created.trail_id) + .access() + .for_role(record_admin_role) + .create(PermissionSet::record_admin_permissions(), None) + .build_and_execute(&client) + .await? + .output; + + println!( + "Role \"{}\" defined with permissions:\n {:?}\n", + role_created.role, role_created.permissions.permissions + ); + + // ------------------------------------------------------------------------- + // Step 3: Issue a capability for the RecordAdmin role + // ------------------------------------------------------------------------- + // A Capability object is minted on-chain and sent to the caller's address + // (or a specified `issued_to` address via CapabilityIssueOptions). + // The holder of this capability can add, delete, and correct records on the trail. + let capability = client + .trail(created.trail_id) + .access() + .for_role(record_admin_role) + .issue_capability(CapabilityIssueOptions::default()) + .build_and_execute(&client) + .await? + .output; + + println!( + "Capability issued!\n Capability ID: {}\n Trail ID: {}\n Role: {}\n Issued to: {}", + capability.capability_id, + capability.target_key, + capability.role, + capability + .issued_to + .map_or_else(|| "any holder (no address restriction)".to_string(), |a| a.to_string()) + ); + + Ok(()) +} \ No newline at end of file diff --git a/examples/audit-trail/README.md b/examples/audit-trail/README.md new file mode 100644 index 00000000..626f2a4a --- /dev/null +++ b/examples/audit-trail/README.md @@ -0,0 +1,147 @@ +# IOTA Audit Trail Examples + +The following code examples demonstrate how to use IOTA Audit Trails for creating structured, role-based audit logs on the IOTA network. + +## Prerequisites + +Examples can be run against: + +- A local IOTA node +- An existing network, e.g., the IOTA testnet + +When setting up a local node, you'll need to publish an audit trail package as described in the IOTA documentation. You'll also need to provide environment variables for your locally deployed audit trail package to run the examples against the local node. + +If running the examples on `testnet`, use the appropriate package IDs for the testnet deployment. + +In case of running the examples against an existing network, this network needs to have a faucet to fund your accounts (the IOTA testnet (`https://api.testnet.iota.cafe`) supports this), and you need to specify this via `API_ENDPOINT`. + +## Environment Variables + +You'll need one or more of the following environment variables depending on your setup: + +| Name | Required for local node | Required for testnet | Required for other node | +| -------------------------- | :---------------------: | :------------------: | :---------------------: | +| IOTA_AUDIT_TRAIL_PKG_ID | x | x | x | +| IOTA_TF_COMPONENTS_PKG_ID | x | | | +| API_ENDPOINT | | x | x | + +> **Note:** On localnet both `IOTA_AUDIT_TRAIL_PKG_ID` and `IOTA_TF_COMPONENTS_PKG_ID` resolve to the same package ID because the TfComponents dependency is published together with the audit trail package. + +## Running Examples + +The publish script prints the required `export` statements, so use `eval` to set the variables in one step: + +```bash +eval $(./audit-trail-move/scripts/publish_package.sh) +``` + +Then run a specific example: + +```bash +cargo run --release --example +``` + +For instance, to run the `01_create_audit_trail` example: + +```bash +eval $(./audit-trail-move/scripts/publish_package.sh) +cargo run --release --example 01_create_audit_trail +``` + +To pass the variables inline instead: + +```bash +IOTA_AUDIT_TRAIL_PKG_ID=0x... IOTA_TF_COMPONENTS_PKG_ID=0x... cargo run --release --example 01_create_audit_trail +``` + +## Examples + +| Name | Information | +| :-------------------------------------------------------------------------------------------------------------------------------------- | :-------------------------------------------------------------------------------------------------------------- | +| [01_create_audit_trail](https://github.com/iotaledger/notarization/tree/main/examples/audit-trail/01_create_audit_trail.rs) | Creates an audit trail, defines a `RecordAdmin` role using the Admin capability, and issues a capability for it. | + +## Key Concepts + +### Audit Trail + +An audit trail is an on-chain object that stores an ordered sequence of records. Each trail has: + +- **Immutable metadata**: Name and description set at creation, never changes +- **Updatable metadata**: A mutable string for operational status or notes +- **Record log**: An append-only sequence of records (text or binary data) +- **Role map**: Named roles with permission sets that control who can do what +- **Locking config**: Optional write, delete-record, and delete-trail locks + +### Role-Based Access Control + +Access to trail operations is controlled via roles and capabilities: + +- **Roles** define a named set of permissions (e.g., `RecordAdmin` with `AddRecord`, `DeleteRecord`, `CorrectRecord`) +- **Capabilities** are on-chain objects issued for a role and held in a wallet — possession of a capability grants the associated permissions on a specific trail +- The trail creator automatically receives an **Admin** capability granting full administrative control (role management, capability issuance, tag management, etc.) + +### Permission Sets + +`PermissionSet` convenience constructors cover common role configurations: + +| Constructor | Permissions granted | +| :-------------------------------- | :------------------------------------------------------------------------------- | +| `admin_permissions()` | AddRoles, UpdateRoles, DeleteRoles, AddCapabilities, RevokeCapabilities, AddRecordTags, DeleteRecordTags | +| `record_admin_permissions()` | AddRecord, DeleteRecord, CorrectRecord | +| `locking_admin_permissions()` | UpdateLockingConfig (and all sub-variants) | +| `cap_admin_permissions()` | AddCapabilities, RevokeCapabilities | +| `tag_admin_permissions()` | AddRecordTags, DeleteRecordTags | +| `metadata_admin_permissions()` | UpdateMetadata, DeleteMetadata | + +### Capability Constraints + +When issuing a capability, `CapabilityIssueOptions` allows restricting its use: + +- **`issued_to`**: Bind the capability to a specific wallet address +- **`valid_from_ms`**: The capability is not valid before this Unix timestamp (ms) +- **`valid_until_ms`**: The capability expires after this Unix timestamp (ms) + +### Locking + +Trails support three independent lock dimensions: + +- **Write lock** (`TimeLock`): Prevents new records from being added +- **Delete-record window** (`LockingWindow`): Time-based or count-based window during which a record can be deleted after creation +- **Delete-trail lock** (`TimeLock`): Prevents the trail itself from being destroyed + +`TimeLock` variants: `None`, `UnlockAt(u32)`, `UnlockAtMs(u64)`, `UntilDestroyed`, `Infinite`. + +## Example Scenarios + +### Audit Log Workflow + +1. **Create** a trail with immutable metadata and an initial record +2. **Define roles** (e.g., `RecordAdmin`, `Auditor`) using the Admin capability +3. **Issue capabilities** to operators or auditors +4. **Add records** using a RecordAdmin capability +5. **Query** records and trail state at any time + +### Compliance Use Cases + +- **Locked write windows** to prevent retroactive record insertion +- **Delete-record windows** to allow corrections within a time limit, then freeze +- **Role separation** to enforce least-privilege access (auditors can read, operators can write) +- **Bound capabilities** to tie a capability to a specific operator address + +## Best Practices + +1. **Separate roles by responsibility**: Use distinct roles for writing records, managing locking, and administering capabilities +2. **Bind capabilities to addresses**: Use `issued_to` to prevent capability sharing +3. **Set validity windows**: Use `valid_from_ms` / `valid_until_ms` to limit capability lifetime +4. **Use record tags**: Define a tag registry on the trail and restrict roles to specific tags for finer-grained access control +5. **Plan locking upfront**: Locking configuration is easier to set at creation than to change later + +## Security Considerations + +- Audit trails and their records are publicly readable on the blockchain +- Private keys control which capabilities a wallet holds +- Bound capabilities (`issued_to`) prevent transfer and unauthorized use +- Delete-trail locks ensure data retention requirements are met +- Revoking a capability adds it to the trail's revoked-capability registry, blocking future use + +For more detailed information about IOTA Audit Trail concepts and advanced usage, refer to the official IOTA documentation. \ No newline at end of file diff --git a/examples/audit-trail/run.sh b/examples/audit-trail/run.sh new file mode 100755 index 00000000..529a9983 --- /dev/null +++ b/examples/audit-trail/run.sh @@ -0,0 +1,37 @@ +#!/bin/bash + +# Script to run all audit trail examples +# Usage: ./run.sh +# Make sure to set IOTA_AUDIT_TRAIL_PKG_ID and IOTA_TF_COMPONENTS_PKG_ID environment variables + +if [[ -z $IOTA_AUDIT_TRAIL_PKG_ID || -z $IOTA_TF_COMPONENTS_PKG_ID ]]; then + echo "Error: IOTA_AUDIT_TRAIL_PKG_ID environment variable is not set" + echo "Usage: IOTA_AUDIT_TRAIL_PKG_ID=0x... IOTA_TF_COMPONENTS_PKG_ID=0x... ./run.sh" + echo "" + echo "On localnet, you can set both variables using:" + echo " eval \$(./audit-trail-move/scripts/publish_package.sh)" + exit 1 +fi + +echo "Running all audit trail examples..." +echo "AuditTrail Package ID: $IOTA_AUDIT_TRAIL_PKG_ID" +echo "TfComponents Package ID: $IOTA_TF_COMPONENTS_PKG_ID" +echo "================================" + +examples=( + "01_create_audit_trail" +) + +for example in "${examples[@]}"; do + echo "" + echo "Running Audit Trail: $example" + echo "------------------------" + cargo run --release --example "$example" + if [ $? -ne 0 ]; then + echo "Error: Failed to run $example" + exit 1 + fi +done + +echo "" +echo "All Audit Trail examples completed successfully!" diff --git a/examples/notarization/01_create_locked_notarization.rs b/examples/notarization/01_create_locked_notarization.rs index e3280550..ab028978 100644 --- a/examples/notarization/01_create_locked_notarization.rs +++ b/examples/notarization/01_create_locked_notarization.rs @@ -4,7 +4,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::Result; -use examples::get_funded_client; +use examples::get_funded_notarization_client; use notarization::core::types::{NotarizationMethod, OnChainNotarization, State, TimeLock}; use product_common::transaction::TransactionOutput; @@ -13,7 +13,7 @@ async fn main() -> Result<()> { println!("Creating a locked notarization example"); // Create a notarization client - let notarization_client = get_funded_client().await?; + let notarization_client = get_funded_notarization_client().await?; // Calculate unlock time (24 hours from now) let now_ts = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(); diff --git a/examples/notarization/02_create_dynamic_notarization.rs b/examples/notarization/02_create_dynamic_notarization.rs index 9bb608df..2c03fab2 100644 --- a/examples/notarization/02_create_dynamic_notarization.rs +++ b/examples/notarization/02_create_dynamic_notarization.rs @@ -4,7 +4,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::Result; -use examples::get_funded_client; +use examples::get_funded_notarization_client; use notarization::core::types::{NotarizationMethod, OnChainNotarization, State, TimeLock}; use product_common::transaction::TransactionOutput; @@ -13,7 +13,7 @@ async fn main() -> Result<()> { println!("Creating a dynamic notarization example"); // Create a notarization client - let notarization_client = get_funded_client().await?; + let notarization_client = get_funded_notarization_client().await?; println!("Creating a simple dynamic notarization without locks..."); diff --git a/examples/notarization/03_update_dynamic_notarization.rs b/examples/notarization/03_update_dynamic_notarization.rs index f7b9dc1d..47fe2792 100644 --- a/examples/notarization/03_update_dynamic_notarization.rs +++ b/examples/notarization/03_update_dynamic_notarization.rs @@ -2,14 +2,14 @@ // SPDX-License-Identifier: Apache-2.0 use anyhow::Result; -use examples::get_funded_client; +use examples::get_funded_notarization_client; use notarization::core::types::State; #[tokio::main] async fn main() -> Result<()> { println!("Demonstrating update on dynamic notarization"); - let notarization_client = get_funded_client().await?; + let notarization_client = get_funded_notarization_client().await?; println!("Creating a dynamic notarization..."); diff --git a/examples/notarization/04_destroy_notarization.rs b/examples/notarization/04_destroy_notarization.rs index db404144..d023e63e 100644 --- a/examples/notarization/04_destroy_notarization.rs +++ b/examples/notarization/04_destroy_notarization.rs @@ -4,7 +4,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::Result; -use examples::get_funded_client; +use examples::get_funded_notarization_client; use notarization::core::types::{State, TimeLock}; #[tokio::main] @@ -12,7 +12,7 @@ async fn main() -> Result<()> { println!("Demonstrating notarization destruction scenarios"); // Create a notarization client - let notarization_client = get_funded_client().await?; + let notarization_client = get_funded_notarization_client().await?; // Scenario 1: Destroy an unlocked dynamic notarization (should succeed) println!("📝 Scenario 1: Creating and destroying an unlocked dynamic notarization..."); diff --git a/examples/notarization/05_update_state.rs b/examples/notarization/05_update_state.rs index 406eb5fd..731df426 100644 --- a/examples/notarization/05_update_state.rs +++ b/examples/notarization/05_update_state.rs @@ -2,14 +2,14 @@ // SPDX-License-Identifier: Apache-2.0 use anyhow::Result; -use examples::get_funded_client; +use examples::get_funded_notarization_client; use notarization::core::types::State; #[tokio::main] async fn main() -> Result<()> { println!("Demonstrating state updates on dynamic notarization"); - let notarization_client = get_funded_client().await?; + let notarization_client = get_funded_notarization_client().await?; println!("Creating a dynamic notarization for state updates..."); diff --git a/examples/notarization/06_update_metadata.rs b/examples/notarization/06_update_metadata.rs index 8bbf47cc..1ed5984b 100644 --- a/examples/notarization/06_update_metadata.rs +++ b/examples/notarization/06_update_metadata.rs @@ -2,14 +2,14 @@ // SPDX-License-Identifier: Apache-2.0 use anyhow::Result; -use examples::get_funded_client; +use examples::get_funded_notarization_client; use notarization::core::types::State; #[tokio::main] async fn main() -> Result<()> { println!("Demonstrating metadata updates on dynamic notarization"); - let notarization_client = get_funded_client().await?; + let notarization_client = get_funded_notarization_client().await?; println!("Creating a dynamic notarization for metadata updates..."); diff --git a/examples/notarization/07_transfer_dynamic_notarization.rs b/examples/notarization/07_transfer_dynamic_notarization.rs index b0473665..1d459d99 100644 --- a/examples/notarization/07_transfer_dynamic_notarization.rs +++ b/examples/notarization/07_transfer_dynamic_notarization.rs @@ -4,7 +4,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::Result; -use examples::get_funded_client; +use examples::get_funded_notarization_client; use iota_sdk::types::base_types::IotaAddress; use notarization::core::types::{State, TimeLock}; @@ -12,7 +12,7 @@ use notarization::core::types::{State, TimeLock}; async fn main() -> Result<()> { println!("Demonstrating notarization transfer scenarios"); - let notarization_client = get_funded_client().await?; + let notarization_client = get_funded_notarization_client().await?; // Generate random addresses for transfer recipients let alice = IotaAddress::random_for_testing_only(); diff --git a/examples/notarization/08_access_read_only_methods.rs b/examples/notarization/08_access_read_only_methods.rs index f42ad05c..a2d89a85 100644 --- a/examples/notarization/08_access_read_only_methods.rs +++ b/examples/notarization/08_access_read_only_methods.rs @@ -4,14 +4,14 @@ use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::Result; -use examples::get_funded_client; +use examples::get_funded_notarization_client; use notarization::core::types::{State, TimeLock}; #[tokio::main] async fn main() -> Result<()> { println!("Demonstrating read-only methods for notarization inspection"); - let notarization_client = get_funded_client().await?; + let notarization_client = get_funded_notarization_client().await?; // Create a comprehensive dynamic notarization for testing println!("Creating a dynamic notarization with comprehensive metadata..."); diff --git a/examples/README.md b/examples/notarization/README.md similarity index 100% rename from examples/README.md rename to examples/notarization/README.md diff --git a/examples/notarization/real-world/01_iot_weather_station.rs b/examples/notarization/real-world/01_iot_weather_station.rs index 8930c7ee..f4268019 100644 --- a/examples/notarization/real-world/01_iot_weather_station.rs +++ b/examples/notarization/real-world/01_iot_weather_station.rs @@ -19,7 +19,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::Result; -use examples::get_funded_client; +use examples::get_funded_notarization_client; use notarization::core::types::State; use serde_json::json; @@ -28,7 +28,7 @@ async fn main() -> Result<()> { println!("🌡️ IoT Weather Station - Dynamic Notarization Example"); println!("=====================================================\n"); - let notarization_client = get_funded_client().await?; + let notarization_client = get_funded_notarization_client().await?; let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); diff --git a/examples/notarization/real-world/02_legal_contract.rs b/examples/notarization/real-world/02_legal_contract.rs index 5678390a..ea7adad0 100644 --- a/examples/notarization/real-world/02_legal_contract.rs +++ b/examples/notarization/real-world/02_legal_contract.rs @@ -19,7 +19,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::Result; -use examples::get_funded_client; +use examples::get_funded_notarization_client; use notarization::core::types::{State, TimeLock}; use serde_json::json; use sha2::{Digest, Sha256}; @@ -29,7 +29,7 @@ async fn main() -> Result<()> { println!("⚖️ Legal Contract - Locked Notarization Example"); println!("===============================================\n"); - let notarization_client = get_funded_client().await?; + let notarization_client = get_funded_notarization_client().await?; // Get current timestamp let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); diff --git a/examples/notarization/run.sh b/examples/notarization/run.sh new file mode 100755 index 00000000..c01565ee --- /dev/null +++ b/examples/notarization/run.sh @@ -0,0 +1,42 @@ +#!/bin/bash + +# Script to run all notarization examples +# Usage: ./run.sh +# Make sure to set IOTA_NOTARIZATION_PKG_ID environment variable + +if [ -z "$IOTA_NOTARIZATION_PKG_ID" ]; then + echo "Error: IOTA_NOTARIZATION_PKG_ID environment variable is not set" + echo "Usage: IOTA_NOTARIZATION_PKG_ID=0x... ./run.sh" + exit 1 +fi + +echo "Running all Notarization examples..." +echo "Package ID: $IOTA_NOTARIZATION_PKG_ID" +echo "================================" + +examples=( + "01_create_locked_notarization" + "02_create_dynamic_notarization" + "03_update_dynamic_notarization" + "04_destroy_notarization" + "05_update_state" + "06_update_metadata" + "07_transfer_dynamic_notarization" + "08_access_read_only_methods" + "01_iot_weather_station" + "02_legal_contract" +) + +for example in "${examples[@]}"; do + echo "" + echo "Running Notarization Example: $example" + echo "------------------------" + cargo run --release --example "$example" + if [ $? -ne 0 ]; then + echo "Error: Failed to run $example" + exit 1 + fi +done + +echo "" +echo "All Notarization examples completed successfully!" diff --git a/examples/run.sh b/examples/run.sh index 56ff40ef..861ea809 100755 --- a/examples/run.sh +++ b/examples/run.sh @@ -1,42 +1,13 @@ #!/bin/bash -# Script to run all notarization examples +# Script to run all examples contained in this directory # Usage: ./run.sh -# Make sure to set IOTA_NOTARIZATION_PKG_ID environment variable - -if [ -z "$IOTA_NOTARIZATION_PKG_ID" ]; then - echo "Error: IOTA_NOTARIZATION_PKG_ID environment variable is not set" - echo "Usage: IOTA_NOTARIZATION_PKG_ID=0x... ./run.sh" - exit 1 -fi - -echo "Running all notarization examples..." -echo "Package ID: $IOTA_NOTARIZATION_PKG_ID" -echo "================================" - -examples=( - "01_create_locked_notarization" - "02_create_dynamic_notarization" - "03_update_dynamic_notarization" - "04_destroy_notarization" - "05_update_state" - "06_update_metadata" - "07_transfer_dynamic_notarization" - "08_access_read_only_methods" - "01_iot_weather_station" - "02_legal_contract" -) - -for example in "${examples[@]}"; do - echo "" - echo "Running: $example" - echo "------------------------" - cargo run --release --example "$example" - if [ $? -ne 0 ]; then - echo "Error: Failed to run $example" - exit 1 - fi -done - -echo "" -echo "All examples completed successfully!" +# Make sure to set the following environment variables: +# - IOTA_NOTARIZATION_PKG_ID: The package ID of the notarization module +# - IOTA_AUDIT_TRAIL_PKG_ID: The package ID of the audit trail module +# - IOTA_TF_COMPONENTS_PKG_ID: The package ID of the tf components module + +./examples/audit-trail/run.sh +printf "\n================================\n" +printf "================================\n\n" +./examples/notarization/run.sh diff --git a/examples/utils/utils.rs b/examples/utils/utils.rs index 5f918f9c..a6bcee64 100644 --- a/examples/utils/utils.rs +++ b/examples/utils/utils.rs @@ -1,38 +1,78 @@ -// Copyright 2020-2025 IOTA Stiftung +// Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 use anyhow::Context; -use iota_sdk::{IOTA_LOCAL_NETWORK_URL, IotaClientBuilder}; +use iota_interaction::types::base_types::ObjectID; +use audit_trail::{AuditTrailClient, PackageOverrides}; +use iota_sdk::{IotaClientBuilder, IOTA_LOCAL_NETWORK_URL}; use notarization::client::{NotarizationClient, NotarizationClientReadOnly}; use product_common::test_utils::{InMemSigner, request_funds}; -pub async fn get_read_only_client() -> anyhow::Result { +async fn get_iota_client() -> anyhow::Result { let api_endpoint = std::env::var("API_ENDPOINT").unwrap_or_else(|_| IOTA_LOCAL_NETWORK_URL.to_string()); - let iota_client = IotaClientBuilder::default() + IotaClientBuilder::default() .build(&api_endpoint) .await - .map_err(|err| anyhow::anyhow!(format!("failed to connect to network; {}", err)))?; + .map_err(|err| anyhow::anyhow!("failed to connect to network; {}", err)) +} + +fn get_package_id_from_env(env_var_name: &str) -> anyhow::Result { + let value = std::env::var(env_var_name) + .with_context(|| format!("env variable '{env_var_name}' must be set in order to run the examples"))?; + + value + .parse() + .with_context(|| format!("invalid package id in {env_var_name}")) +} - let package_id = std::env::var("IOTA_NOTARIZATION_PKG_ID") - .map_err(|e| { - anyhow::anyhow!("env variable IOTA_NOTARIZATION_PKG_ID must be set in order to run the examples").context(e) - }) - .and_then(|pkg_str| pkg_str.parse().context("invalid package id"))?; +pub async fn get_notarization_read_only_client() -> anyhow::Result { + let iota_client = get_iota_client().await?; + + let package_id = get_package_id_from_env("IOTA_NOTARIZATION_PKG_ID")?; NotarizationClientReadOnly::new_with_pkg_id(iota_client, package_id) .await .context("failed to create a read-only NotarizationClient") } -pub async fn get_funded_client() -> Result, anyhow::Error> { +pub async fn get_funded_notarization_client() -> Result, anyhow::Error> { let signer = InMemSigner::new(); let sender_address = signer.get_address().await?; request_funds(&sender_address).await?; - let read_only_client = get_read_only_client().await?; + let read_only_client = get_notarization_read_only_client().await?; let notarization_client: NotarizationClient = NotarizationClient::new(read_only_client, signer).await?; Ok(notarization_client) } + +pub async fn get_funded_audit_trail_client() -> Result, anyhow::Error> { + let iota_client = get_iota_client().await?; + + let audit_trail_pkg_id = + get_package_id_from_env("IOTA_AUDIT_TRAIL_PKG_ID")?; + + let tf_components_pkg_id = + get_package_id_from_env("IOTA_TF_COMPONENTS_PKG_ID")?; + + let signer = InMemSigner::new(); + let sender_address = signer.get_address().await?; + request_funds(&sender_address).await?; + + let client = AuditTrailClient::from_iota_client( + iota_client, + Some(PackageOverrides { + audit_trail_package_id: Some(audit_trail_pkg_id), + tf_components_package_id: Some(tf_components_pkg_id), + }), + ) + .await + .map_err(|e| anyhow::anyhow!("failed to create AuditTrailClient: {e}"))?; + + client + .with_signer(signer) + .await + .map_err(|e| anyhow::anyhow!("failed to attach signer to AuditTrailClient: {e}")) +} From 7a62e536386603d0c70d4b541525e02e5474f09d Mon Sep 17 00:00:00 2001 From: Yasir Date: Thu, 2 Apr 2026 17:08:32 +0300 Subject: [PATCH 130/189] test: expand audit trail access coverage --- .../src/core/internal/capability.rs | 62 +++++- audit-trail-rs/src/core/types/role_map.rs | 53 +++++ audit-trail-rs/tests/e2e/access.rs | 199 +++++++++++++++++ audit-trail-rs/tests/e2e/records.rs | 201 ++++++++++++++++++ audit-trail-rs/tests/e2e/trail.rs | 79 +++++++ 5 files changed, 588 insertions(+), 6 deletions(-) diff --git a/audit-trail-rs/src/core/internal/capability.rs b/audit-trail-rs/src/core/internal/capability.rs index d7449a33..90ecd048 100644 --- a/audit-trail-rs/src/core/internal/capability.rs +++ b/audit-trail-rs/src/core/internal/capability.rs @@ -201,8 +201,8 @@ mod tests { let valid_roles = HashSet::from(["Writer".to_string()]); let revoked_ids = HashSet::from([revoked_cap_id]); - let revoked_cap = make_capability(revoked_cap_id, trail_id, "Writer", None); - let valid_cap = make_capability(valid_cap_id, trail_id, "Writer", None); + let revoked_cap = make_capability(revoked_cap_id, trail_id, "Writer", None, None, None); + let valid_cap = make_capability(valid_cap_id, trail_id, "Writer", None, None, None); assert!(!capability_matches(&revoked_cap, owner, &revoked_ids, &|cap| cap .matches_target_and_role(trail_id, &valid_roles))); @@ -216,21 +216,71 @@ mod tests { let other_owner = IotaAddress::random_for_testing_only(); let trail_id = dbg_object_id(4); let valid_roles = HashSet::from(["Writer".to_string()]); - let cap = make_capability(dbg_object_id(5), trail_id, "Writer", Some(other_owner)); + let cap = make_capability(dbg_object_id(5), trail_id, "Writer", Some(other_owner), None, None); assert!(!capability_matches(&cap, owner, &HashSet::new(), &|candidate| { candidate.matches_target_and_role(trail_id, &valid_roles) })); } - fn make_capability(id: ObjectID, trail_id: ObjectID, role: &str, issued_to: Option) -> Capability { + #[test] + fn capability_matches_accepts_unbound_capability_for_matching_role() { + let owner = IotaAddress::random_for_testing_only(); + let trail_id = dbg_object_id(6); + let valid_roles = HashSet::from(["Writer".to_string()]); + let cap = make_capability(dbg_object_id(7), trail_id, "Writer", None, None, None); + + assert!(capability_matches(&cap, owner, &HashSet::new(), &|candidate| { + candidate.matches_target_and_role(trail_id, &valid_roles) + })); + } + + #[test] + fn capability_matches_rejects_non_matching_role() { + let owner = IotaAddress::random_for_testing_only(); + let trail_id = dbg_object_id(8); + let valid_roles = HashSet::from(["Writer".to_string()]); + let cap = make_capability(dbg_object_id(9), trail_id, "Reader", None, None, None); + + assert!(!capability_matches(&cap, owner, &HashSet::new(), &|candidate| { + candidate.matches_target_and_role(trail_id, &valid_roles) + })); + } + + #[test] + fn capability_matches_leaves_time_constraints_to_on_chain_validation() { + let owner = IotaAddress::random_for_testing_only(); + let trail_id = dbg_object_id(10); + let valid_roles = HashSet::from(["Writer".to_string()]); + let cap = make_capability( + dbg_object_id(11), + trail_id, + "Writer", + Some(owner), + Some(1_700_000_000_000), + Some(1_700_000_005_000), + ); + + assert!(capability_matches(&cap, owner, &HashSet::new(), &|candidate| { + candidate.matches_target_and_role(trail_id, &valid_roles) + })); + } + + fn make_capability( + id: ObjectID, + trail_id: ObjectID, + role: &str, + issued_to: Option, + valid_from: Option, + valid_until: Option, + ) -> Capability { Capability { id: UID::new(id), target_key: trail_id, role: role.to_string(), issued_to, - valid_from: None, - valid_until: None, + valid_from, + valid_until, } } } diff --git a/audit-trail-rs/src/core/types/role_map.rs b/audit-trail-rs/src/core/types/role_map.rs index 084ca446..ad33a07b 100644 --- a/audit-trail-rs/src/core/types/role_map.rs +++ b/audit-trail-rs/src/core/types/role_map.rs @@ -12,6 +12,7 @@ use iota_interaction::types::programmable_transaction_builder::ProgrammableTrans use iota_interaction::types::transaction::Argument; use iota_interaction::{MoveType, ident_str}; use serde::{Deserialize, Serialize}; +use serde_aux::field_attributes::deserialize_option_number_from_string; use super::permission::Permission; use crate::core::internal::move_collections::{deserialize_vec_map, deserialize_vec_set}; @@ -139,8 +140,10 @@ pub struct Capability { /// Capability holder, if the capability is assigned to an address. pub issued_to: Option, /// Millisecond timestamp at which the capability becomes valid. + #[serde(deserialize_with = "deserialize_option_number_from_string")] pub valid_from: Option, /// Millisecond timestamp at which the capability expires. + #[serde(deserialize_with = "deserialize_option_number_from_string")] pub valid_until: Option, } @@ -159,3 +162,53 @@ impl MoveType for Capability { Self::type_tag(package) } } + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::Capability; + use iota_interaction::types::base_types::{IotaAddress, dbg_object_id}; + use iota_interaction::types::id::UID; + + #[test] + fn capability_deserializes_string_encoded_time_constraints() { + let issued_to = IotaAddress::random_for_testing_only(); + let capability = Capability { + id: UID::new(dbg_object_id(1)), + target_key: dbg_object_id(2), + role: "Writer".to_string(), + issued_to: Some(issued_to), + valid_from: None, + valid_until: None, + }; + + let mut value = serde_json::to_value(capability).expect("capability serializes"); + value["valid_from"] = json!("1700000000000"); + value["valid_until"] = json!("1700000005000"); + + let decoded: Capability = serde_json::from_value(value).expect("capability deserializes"); + + assert_eq!(decoded.valid_from, Some(1_700_000_000_000)); + assert_eq!(decoded.valid_until, Some(1_700_000_005_000)); + assert_eq!(decoded.issued_to, Some(issued_to)); + } + + #[test] + fn capability_deserializes_absent_time_constraints() { + let capability = Capability { + id: UID::new(dbg_object_id(4)), + target_key: dbg_object_id(5), + role: "Writer".to_string(), + issued_to: None, + valid_from: None, + valid_until: None, + }; + + let value = serde_json::to_value(capability).expect("capability serializes"); + let decoded: Capability = serde_json::from_value(value).expect("capability deserializes"); + + assert_eq!(decoded.valid_from, None); + assert_eq!(decoded.valid_until, None); + } +} diff --git a/audit-trail-rs/tests/e2e/access.rs b/audit-trail-rs/tests/e2e/access.rs index cfcff048..94b4e2d0 100644 --- a/audit-trail-rs/tests/e2e/access.rs +++ b/audit-trail-rs/tests/e2e/access.rs @@ -73,6 +73,89 @@ async fn update_role_permissions_then_issue_capability() -> anyhow::Result<()> { Ok(()) } +#[tokio::test] +async fn delegated_role_and_capability_admins_can_enable_record_writes() -> anyhow::Result<()> { + let admin = get_funded_test_client().await?; + let role_admin = get_funded_test_client().await?; + let cap_admin = get_funded_test_client().await?; + let record_admin = get_funded_test_client().await?; + let trail_id = admin.create_test_trail(Data::text("delegated-access-flow")).await?; + + admin + .create_role( + trail_id, + "RoleAdmin", + PermissionSet::role_admin_permissions().permissions, + None, + ) + .await?; + admin + .create_role( + trail_id, + "CapAdmin", + PermissionSet::cap_admin_permissions().permissions, + None, + ) + .await?; + admin + .issue_cap( + trail_id, + "RoleAdmin", + CapabilityIssueOptions { + issued_to: Some(role_admin.sender_address()), + ..CapabilityIssueOptions::default() + }, + ) + .await?; + admin + .issue_cap( + trail_id, + "CapAdmin", + CapabilityIssueOptions { + issued_to: Some(cap_admin.sender_address()), + ..CapabilityIssueOptions::default() + }, + ) + .await?; + + role_admin + .create_role( + trail_id, + "RecordAdmin", + PermissionSet::record_admin_permissions().permissions, + None, + ) + .await?; + cap_admin + .issue_cap( + trail_id, + "RecordAdmin", + CapabilityIssueOptions { + issued_to: Some(record_admin.sender_address()), + ..CapabilityIssueOptions::default() + }, + ) + .await?; + + let added = record_admin + .trail(trail_id) + .records() + .add(Data::text("delegated write"), None, None) + .build_and_execute(&record_admin) + .await? + .output; + + assert_eq!(added.trail_id, trail_id); + assert_eq!(added.sequence_number, 1); + + let record = admin.trail(trail_id).records().get(1).await?; + assert_eq!(record.sequence_number, 1); + assert_eq!(record.added_by, record_admin.sender_address()); + assert_eq!(record.data, Data::text("delegated write")); + + Ok(()) +} + #[tokio::test] async fn create_role_rejects_undefined_role_tags() -> anyhow::Result<()> { let client = get_funded_test_client().await?; @@ -129,6 +212,122 @@ async fn update_role_permissions_rejects_undefined_role_tags() -> anyhow::Result Ok(()) } +#[tokio::test] +async fn issue_capability_for_nonexistent_role_fails() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client.create_test_trail(Data::text("missing-role-cap")).await?; + + let issued = client + .trail(trail_id) + .access() + .for_role("NonExistentRole") + .issue_capability(CapabilityIssueOptions::default()) + .build_and_execute(&client) + .await; + + assert!(issued.is_err(), "issuing a capability for a missing role must fail"); + + Ok(()) +} + +#[tokio::test] +async fn issue_capability_requires_add_capabilities_permission() -> anyhow::Result<()> { + let admin = get_funded_test_client().await?; + let operator = get_funded_test_client().await?; + let trail_id = admin.create_test_trail(Data::text("missing-cap-permission")).await?; + + admin + .create_role(trail_id, "NoCapPerm", vec![Permission::AddRecord], None) + .await?; + admin + .create_role( + trail_id, + "RecordAdmin", + PermissionSet::record_admin_permissions().permissions, + None, + ) + .await?; + admin + .issue_cap( + trail_id, + "NoCapPerm", + CapabilityIssueOptions { + issued_to: Some(operator.sender_address()), + ..CapabilityIssueOptions::default() + }, + ) + .await?; + + let issued = operator + .trail(trail_id) + .access() + .for_role("RecordAdmin") + .issue_capability(CapabilityIssueOptions::default()) + .build_and_execute(&operator) + .await; + + assert!( + issued.is_err(), + "issuing a capability without AddCapabilities permission must fail" + ); + + Ok(()) +} + +#[tokio::test] +async fn revoke_capability_requires_revoke_capabilities_permission() -> anyhow::Result<()> { + let admin = get_funded_test_client().await?; + let no_revoke = get_funded_test_client().await?; + let target = get_funded_test_client().await?; + let trail_id = admin.create_test_trail(Data::text("missing-revoke-permission")).await?; + + admin + .create_role(trail_id, "NoRevokePerm", vec![Permission::AddRecord], None) + .await?; + admin + .create_role( + trail_id, + "RecordAdmin", + PermissionSet::record_admin_permissions().permissions, + None, + ) + .await?; + admin + .issue_cap( + trail_id, + "NoRevokePerm", + CapabilityIssueOptions { + issued_to: Some(no_revoke.sender_address()), + ..CapabilityIssueOptions::default() + }, + ) + .await?; + let target_cap = admin + .issue_cap( + trail_id, + "RecordAdmin", + CapabilityIssueOptions { + issued_to: Some(target.sender_address()), + ..CapabilityIssueOptions::default() + }, + ) + .await?; + + let revoked = no_revoke + .trail(trail_id) + .access() + .revoke_capability(target_cap.capability_id, target_cap.valid_until) + .build_and_execute(&no_revoke) + .await; + + assert!( + revoked.is_err(), + "revoking a capability without RevokeCapabilities permission must fail" + ); + + Ok(()) +} + #[tokio::test] async fn delete_role_prevents_new_capability_issuance() -> anyhow::Result<()> { let client = get_funded_test_client().await?; diff --git a/audit-trail-rs/tests/e2e/records.rs b/audit-trail-rs/tests/e2e/records.rs index 7aeddbe3..9fafc7be 100644 --- a/audit-trail-rs/tests/e2e/records.rs +++ b/audit-trail-rs/tests/e2e/records.rs @@ -7,6 +7,7 @@ use audit_trail::core::types::{ use audit_trail::error::Error; use iota_interaction::types::base_types::ObjectID; use product_common::core_client::CoreClient; +use tokio::time::{Duration, sleep}; use crate::client::{TestClient, get_funded_test_client}; @@ -172,6 +173,38 @@ async fn add_tagged_record_requires_trail_defined_tag() -> anyhow::Result<()> { Ok(()) } +#[tokio::test] +async fn add_record_requires_add_record_permission() -> anyhow::Result<()> { + let admin = get_funded_test_client().await?; + let writer = get_funded_test_client().await?; + let trail_id = admin.create_test_trail(Data::text("records-add-permission")).await?; + let records = writer.trail(trail_id).records(); + + admin + .create_role(trail_id, "NoAddRecord", [Permission::DeleteRecord], None) + .await?; + admin + .issue_cap( + trail_id, + "NoAddRecord", + CapabilityIssueOptions { + issued_to: Some(writer.sender_address()), + ..CapabilityIssueOptions::default() + }, + ) + .await?; + + let denied = records + .add(Data::text("should fail"), None, None) + .build_and_execute(&writer) + .await; + + assert!(denied.is_err(), "adding without AddRecord permission must fail"); + assert_eq!(admin.trail(trail_id).records().record_count().await?, 1); + + Ok(()) +} + #[tokio::test] async fn add_record_skips_revoked_capability_when_valid_one_exists() -> anyhow::Result<()> { let admin = get_funded_test_client().await?; @@ -224,6 +257,46 @@ async fn add_record_skips_revoked_capability_when_valid_one_exists() -> anyhow:: Ok(()) } +#[tokio::test] +async fn revoked_capability_cannot_add_record_without_fallback() -> anyhow::Result<()> { + let admin = get_funded_test_client().await?; + let writer = get_funded_test_client().await?; + let trail_id = admin.create_test_trail(Data::text("records-revoked-hard-fail")).await?; + let records = writer.trail(trail_id).records(); + let role_name = "RecordWriter"; + + admin + .create_role(trail_id, role_name, [Permission::AddRecord], None) + .await?; + let issued = admin + .issue_cap( + trail_id, + role_name, + CapabilityIssueOptions { + issued_to: Some(writer.sender_address()), + ..CapabilityIssueOptions::default() + }, + ) + .await?; + + admin + .trail(trail_id) + .access() + .revoke_capability(issued.capability_id, issued.valid_until) + .build_and_execute(&admin) + .await?; + + let denied = records + .add(Data::text("should fail"), None, None) + .build_and_execute(&writer) + .await; + + assert!(denied.is_err(), "revoked capabilities must not authorize writes"); + assert_eq!(admin.trail(trail_id).records().record_count().await?, 1); + + Ok(()) +} + #[tokio::test] async fn add_tagged_record_skips_revoked_capability_when_valid_one_exists() -> anyhow::Result<()> { let admin = get_funded_test_client().await?; @@ -287,6 +360,98 @@ async fn add_tagged_record_skips_revoked_capability_when_valid_one_exists() -> a Ok(()) } +#[tokio::test] +async fn add_record_respects_valid_from_constraint() -> anyhow::Result<()> { + let admin = get_funded_test_client().await?; + let writer = get_funded_test_client().await?; + let trail_id = admin.create_test_trail(Data::text("records-valid-from")).await?; + let records = writer.trail(trail_id).records(); + let role_name = "RecordWriter"; + + admin + .create_role(trail_id, role_name, [Permission::AddRecord], None) + .await?; + let valid_from_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_millis() as u64 + + 15_000; + admin + .issue_cap( + trail_id, + role_name, + CapabilityIssueOptions { + issued_to: Some(writer.sender_address()), + valid_from_ms: Some(valid_from_ms), + valid_until_ms: None, + }, + ) + .await?; + + let denied = records + .add(Data::text("too early"), None, None) + .build_and_execute(&writer) + .await; + assert!(denied.is_err(), "writes before valid_from must fail"); + + sleep(Duration::from_secs(16)).await; + + let added = records + .add(Data::text("on time"), None, None) + .build_and_execute(&writer) + .await? + .output; + + assert_eq!(added.sequence_number, 1); + assert_text_data(records.get(1).await?.data, "on time"); + + Ok(()) +} + +#[tokio::test] +async fn add_record_respects_valid_until_constraint() -> anyhow::Result<()> { + let admin = get_funded_test_client().await?; + let writer = get_funded_test_client().await?; + let trail_id = admin.create_test_trail(Data::text("records-valid-until")).await?; + let records = writer.trail(trail_id).records(); + let role_name = "RecordWriter"; + + admin + .create_role(trail_id, role_name, [Permission::AddRecord], None) + .await?; + let valid_until_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_millis() as u64 + + 15_000; + admin + .issue_cap( + trail_id, + role_name, + CapabilityIssueOptions { + issued_to: Some(writer.sender_address()), + valid_from_ms: None, + valid_until_ms: Some(valid_until_ms), + }, + ) + .await?; + + let added = records + .add(Data::text("before expiry"), None, None) + .build_and_execute(&writer) + .await? + .output; + assert_eq!(added.sequence_number, 1); + + sleep(Duration::from_secs(16)).await; + + let denied = records + .add(Data::text("after expiry"), None, None) + .build_and_execute(&writer) + .await; + assert!(denied.is_err(), "writes after valid_until must fail"); + + Ok(()) +} + #[tokio::test] async fn add_record_allows_mixed_data_variants() -> anyhow::Result<()> { let client = get_funded_test_client().await?; @@ -575,6 +740,42 @@ async fn delete_records_batch_respects_limit_and_deletes_oldest_first() -> anyho Ok(()) } +#[tokio::test] +async fn delete_records_batch_requires_delete_all_records_permission() -> anyhow::Result<()> { + let admin = get_funded_test_client().await?; + let operator = get_funded_test_client().await?; + let trail_id = admin.create_test_trail(Data::text("batch-delete-permission")).await?; + let records = operator.trail(trail_id).records(); + + admin + .create_role( + trail_id, + "TrailDeleteOnly", + [Permission::DeleteAuditTrail], + None, + ) + .await?; + admin + .issue_cap( + trail_id, + "TrailDeleteOnly", + CapabilityIssueOptions { + issued_to: Some(operator.sender_address()), + ..CapabilityIssueOptions::default() + }, + ) + .await?; + + let denied = records.delete_records_batch(10).build_and_execute(&operator).await; + assert!( + denied.is_err(), + "batch deletion must require DeleteAllRecords permission" + ); + assert_eq!(admin.trail(trail_id).records().record_count().await?, 1); + + Ok(()) +} + #[tokio::test] async fn list_and_pagination_support_sparse_sequence_numbers() -> anyhow::Result<()> { let client = get_funded_test_client().await?; diff --git a/audit-trail-rs/tests/e2e/trail.rs b/audit-trail-rs/tests/e2e/trail.rs index 5506416d..4fe0c98e 100644 --- a/audit-trail-rs/tests/e2e/trail.rs +++ b/audit-trail-rs/tests/e2e/trail.rs @@ -347,6 +347,85 @@ async fn update_metadata_does_not_affect_immutable_metadata() -> anyhow::Result< Ok(()) } +#[tokio::test] +async fn update_metadata_requires_permission() -> anyhow::Result<()> { + let admin = get_funded_test_client().await?; + let metadata_user = get_funded_test_client().await?; + let trail_id = admin.create_test_trail(Data::text("trail-update-meta-denied")).await?; + + admin + .create_role(trail_id, "NoMetadataPerm", vec![Permission::AddRecord], None) + .await?; + admin + .issue_cap( + trail_id, + "NoMetadataPerm", + CapabilityIssueOptions { + issued_to: Some(metadata_user.sender_address()), + ..CapabilityIssueOptions::default() + }, + ) + .await?; + + let updated = metadata_user + .trail(trail_id) + .update_metadata(Some("should fail".to_string())) + .build_and_execute(&metadata_user) + .await; + + assert!( + updated.is_err(), + "updating metadata without UpdateMetadata permission must fail" + ); + assert_eq!(admin.trail(trail_id).get().await?.updatable_metadata, None); + + Ok(()) +} + +#[tokio::test] +async fn revoked_capability_cannot_update_metadata() -> anyhow::Result<()> { + let admin = get_funded_test_client().await?; + let metadata_user = get_funded_test_client().await?; + let trail_id = admin.create_test_trail(Data::text("trail-update-meta-revoked")).await?; + + admin + .create_role( + trail_id, + "MetadataAdmin", + vec![Permission::UpdateMetadata], + None, + ) + .await?; + let issued = admin + .issue_cap( + trail_id, + "MetadataAdmin", + CapabilityIssueOptions { + issued_to: Some(metadata_user.sender_address()), + ..CapabilityIssueOptions::default() + }, + ) + .await?; + + admin + .trail(trail_id) + .access() + .revoke_capability(issued.capability_id, issued.valid_until) + .build_and_execute(&admin) + .await?; + + let updated = metadata_user + .trail(trail_id) + .update_metadata(Some("should fail".to_string())) + .build_and_execute(&metadata_user) + .await; + + assert!(updated.is_err(), "revoked capabilities must not update metadata"); + assert_eq!(admin.trail(trail_id).get().await?.updatable_metadata, None); + + Ok(()) +} + #[tokio::test] async fn delete_audit_trail_fails_when_records_exist() -> anyhow::Result<()> { let client = get_funded_test_client().await?; From 5cf23d6cd86b960971e3b1b6cdb2701b04bf490a Mon Sep 17 00:00:00 2001 From: chrisgitiota Date: Thu, 2 Apr 2026 16:13:35 +0200 Subject: [PATCH 131/189] Readme file explaing the RoleMap concept usefull for later wiki docs --- .../src/core/types/RoleMap-README.md | 340 ++++++++++++++++++ 1 file changed, 340 insertions(+) create mode 100644 audit-trail-rs/src/core/types/RoleMap-README.md diff --git a/audit-trail-rs/src/core/types/RoleMap-README.md b/audit-trail-rs/src/core/types/RoleMap-README.md new file mode 100644 index 00000000..86668204 --- /dev/null +++ b/audit-trail-rs/src/core/types/RoleMap-README.md @@ -0,0 +1,340 @@ +# RoleMap — Role-Based Access Control for Audit Trails + +A `RoleMap` is the access control registry embedded in every audit trail. +It defines who may perform which operations by combining two primitives: + +- **Roles** — named permission sets stored on the trail. +- **Capabilities** — on-chain objects held by users, each linked to one role. + +Every operation on a trail (adding a record, deleting a role, revoking a +capability, …) requires the caller to present a `Capability`. The `RoleMap` +validates the capability before allowing the operation. + +--- + +## Concepts + +### Roles + +A role is a named set of `Permission` values, for example: + +| Role name | Permissions | +|:----------------|:--------------------------------------------| +| `Admin` | AddRoles, UpdateRoles, DeleteRoles, AddCapabilities, RevokeCapabilities, AddRecordTags, DeleteRecordTags | +| `RecordAdmin` | AddRecord, DeleteRecord, CorrectRecord | +| `LockingAdmin` | UpdateLockingConfig (and sub-variants) | +| `Auditor` | *(read-only — no write permissions needed)* | + +Roles are identified by a unique string name within the trail. Multiple +capabilities can be issued for the same role, one per user or service that +should share that access level. + +Roles may optionally carry a `RoleTags` allowlist (see [Record Tags](#record-tags-and-roletags)). + +### Capabilities + +A `Capability` is an on-chain object owned by a wallet address. It records: + +| Field | Meaning | +|:--------------|:----------------------------------------------------------------------| +| `target_key` | The `ObjectID` of the trail this capability is valid for. | +| `role` | The role name — determines which permissions the holder has. | +| `issued_to` | Optional address binding; only that address may present the cap. | +| `valid_from` | Optional Unix-ms timestamp before which the cap is not yet active. | +| `valid_until` | Optional Unix-ms timestamp after which the cap expires. | + +Possessing a capability does **not** automatically grant access. The `RoleMap` +validates all fields above on every call before the operation is executed. + +### The Admin Role + +When a trail is created, the `RoleMap` initialises with exactly one role — +the **initial admin role** (named `"Admin"`). A corresponding capability +object is minted and transferred to the trail creator (or a custom address +supplied via `with_admin`). + +The Admin role is protected by two invariants: +1. It can **never be deleted**. +2. Its permission set can only be updated to a set that still includes all + configured role- and capability-admin permissions. + +Initial admin capabilities are tracked in `initial_admin_cap_ids` and must be +managed through dedicated entry-points (`revoke_initial_admin_capability`, +`destroy_initial_admin_capability`). + +--- + +## Lifecycle + +### 1 — Trail is created + +``` +Trail creator ──create_trail()──► AuditTrail (shared object) + │ + └── RoleMap + ├── roles: { "Admin" → [AddRoles, …] } + ├── initial_admin_role_name: "Admin" + └── initial_admin_cap_ids: { cap_id } + ◄── Admin Capability (owned object, transferred to creator) +``` + +### 2 — Admin defines additional roles + +The trail creator (Admin capability holder) defines a `RecordAdmin` role: + +``` +Admin Capability + create_role("RecordAdmin", [AddRecord, DeleteRecord, CorrectRecord]) + ──► RoleMap.roles: { "Admin" → […], "RecordAdmin" → [AddRecord, DeleteRecord, CorrectRecord] } +``` + +### 3 — Admin issues capabilities to operators + +``` +Admin Capability + issue_capability("RecordAdmin", issued_to = operator_address) + ──► RecordAdmin Capability (owned object, transferred to operator) +``` + +### 4 — Operator uses their capability + +``` +RecordAdmin Capability + add_record(trail, data) + ──► RoleMap.assert_capability_valid(cap, AddRecord) // validated + ──► Record appended to trail +``` + +### 5 — Admin revokes a capability + +``` +Admin Capability + revoke_capability(cap_id, valid_until) + ──► RoleMap.revoked_capabilities: { cap_id → valid_until_ms } +``` + +The capability object still exists on-chain but is rejected by +`assert_capability_valid`. The holder can no longer use it. + +--- + +## Rust API Quick Reference + +### Creating a trail and obtaining the Admin capability + +```rust +use audit_trail::core::types::{Data, InitialRecord, ImmutableMetadata}; + +let created = client + .create_trail() + .with_trail_metadata(ImmutableMetadata::new("My Trail".into(), None)) + .with_initial_record(InitialRecord::new(Data::text("first entry"), None, None)) + .finish() + .build_and_execute(&client) + .await? + .output; // TrailCreated { trail_id, creator, timestamp } + +// The Admin capability is now in the creator's wallet. +``` + +### Defining a new role + +```rust +use audit_trail::core::types::PermissionSet; + +client + .trail(created.trail_id) + .access() + .for_role("RecordAdmin") + .create(PermissionSet::record_admin_permissions(), None) + .build_and_execute(&client) + .await?; +``` + +### Issuing a capability + +```rust +use audit_trail::core::types::CapabilityIssueOptions; + +// Unrestricted — any holder may use this capability +let cap = client + .trail(created.trail_id) + .access() + .for_role("RecordAdmin") + .issue_capability(CapabilityIssueOptions::default()) + .build_and_execute(&client) + .await? + .output; // CapabilityIssued { capability_id, target_key, role, … } + +// Address-bound and time-limited +let cap = client + .trail(created.trail_id) + .access() + .for_role("RecordAdmin") + .issue_capability(CapabilityIssueOptions { + issued_to: Some(operator_address), + valid_from_ms: None, + valid_until_ms: Some(1_800_000_000_000), // expires at this Unix-ms timestamp + }) + .build_and_execute(&client) + .await? + .output; +``` + +### Revoking a capability + +```rust +client + .trail(trail_id) + .access() + .revoke_capability(cap.capability_id, cap.valid_until) + .build_and_execute(&client) + .await?; +``` + +### Cleaning up the denylist + +```rust +// Removes all denylist entries whose valid_until has already passed. +client + .trail(trail_id) + .access() + .cleanup_revoked_capabilities() + .build_and_execute(&client) + .await?; +``` + +### Updating a role's permissions + +```rust +use audit_trail::core::types::{Permission, PermissionSet}; +use std::collections::HashSet; + +client + .trail(trail_id) + .access() + .for_role("RecordAdmin") + .update_permissions( + PermissionSet { + permissions: HashSet::from([Permission::AddRecord, Permission::CorrectRecord]), + }, + None, // no RoleTags change + ) + .build_and_execute(&client) + .await?; +``` + +### Deleting a role + +```rust +client + .trail(trail_id) + .access() + .for_role("RecordAdmin") + .delete() + .build_and_execute(&client) + .await?; +// Note: the initial admin role ("Admin") cannot be deleted. +``` + +--- + +## Record Tags and RoleTags + +Tags are string labels that can be attached to individual records. They are +managed through a **tag registry** on the trail: a tag must be registered +before it can be used on a record or referenced by a role. + +### Why use tags? + +Tags enable fine-grained access control beyond simple permission checks. For +example, a legal department may only be allowed to read records tagged +`"legal"`, while the finance team works with records tagged `"finance"`. + +### How tags interact with roles + +A role may carry an optional `RoleTags` allowlist. When a capability holder +adds a record with a tag, the `RoleMap` checks that: + +1. The tag is registered in the trail's tag registry. +2. The role associated with the capability includes the requested tag in its + `RoleTags` allowlist. + +If either check fails the transaction is rejected. + +### Example — tagged records + +```rust +// 1. Create trail with a tag registry +let created = client + .create_trail() + .with_record_tags(["finance", "legal"]) + .with_initial_record(InitialRecord::new(Data::text("opening entry"), None, None)) + .finish() + .build_and_execute(&client) + .await? + .output; + +// 2. Create a role that may only write "finance" tagged records +use audit_trail::core::types::RoleTags; + +client + .trail(created.trail_id) + .access() + .for_role("FinanceWriter") + .create( + PermissionSet { permissions: HashSet::from([Permission::AddRecord]) }, + Some(RoleTags::new(["finance"])), + ) + .build_and_execute(&client) + .await?; +``` + +A `FinanceWriter` capability holder can add records tagged `"finance"` but not +records tagged `"legal"`. + +--- + +## Capability Validation Rules + +`assert_capability_valid` rejects a capability if any of the following hold: + +| Check | Error | +|:------------------------|:------------------------------------| +| `target_key` mismatch | `ECapabilityTargetKeyMismatch` | +| Role does not exist | `ERoleDoesNotExist` | +| Permission not in role | `ECapabilityPermissionDenied` | +| ID in revoked denylist | `ECapabilityHasBeenRevoked` | +| Outside validity window | `ECapabilityTimeConstraintsNotMet` | +| `issued_to` mismatch | `ECapabilityIssuedToMismatch` | + +--- + +## Denylist Management + +The `RoleMap` uses a **denylist** (not an allowlist) for revocation. This +keeps on-chain storage proportional to the number of *currently revoked* +capabilities, not the total number ever issued. + +Implications: + +- **Off-chain tracking is required.** Users must maintain a record of every + issued capability ID and its `valid_until` value so the correct ID can be + passed to `revoke_capability`. +- **Provide `valid_until` when revoking.** The stored value lets the denylist + entry be cleaned up automatically once it expires. +- **Call `cleanup_revoked_capabilities` periodically** to remove expired + entries and keep storage costs low. +- Capabilities revoked without a `valid_until` stay in the denylist until + explicitly destroyed. + +--- + +## Permission Sets + +`PermissionSet` provides convenience constructors for common role profiles: + +| Constructor | Permissions | +|:------------------------------|:-------------------------------------------------------------------------------| +| `admin_permissions()` | AddRoles, UpdateRoles, DeleteRoles, AddCapabilities, RevokeCapabilities, AddRecordTags, DeleteRecordTags | +| `record_admin_permissions()` | AddRecord, DeleteRecord, CorrectRecord | +| `locking_admin_permissions()` | UpdateLockingConfig (and all sub-variants) | +| `cap_admin_permissions()` | AddCapabilities, RevokeCapabilities | +| `tag_admin_permissions()` | AddRecordTags, DeleteRecordTags | +| `metadata_admin_permissions()`| UpdateMetadata, DeleteMetadata | From 1d484bd909ab0ba0842e89368bf8c69a7ce6ff24 Mon Sep 17 00:00:00 2001 From: Yasir Date: Thu, 2 Apr 2026 17:21:03 +0300 Subject: [PATCH 132/189] fix: enforce tag access checks on record deletion --- audit-trail-move/sources/audit_trail.move | 11 ++ audit-trail-move/tests/locking_tests.move | 181 ++++++++++++++++++++++ audit-trail-move/tests/record_tests.move | 175 ++++++++++++++++++++- 3 files changed, 366 insertions(+), 1 deletion(-) diff --git a/audit-trail-move/sources/audit_trail.move b/audit-trail-move/sources/audit_trail.move index 2fb5a92d..8bfd33c4 100644 --- a/audit-trail-move/sources/audit_trail.move +++ b/audit-trail-move/sources/audit_trail.move @@ -272,6 +272,14 @@ fun assert_record_tag_allowed( assert!(record_tags::role_allows(&self.roles, cap, requested_tag), ERecordTagNotAllowed); } +fun assert_record_access_allowed( + self: &AuditTrail, + cap: &Capability, + record: &Record, +) { + assert_record_tag_allowed(self, cap, record::tag(record)); +} + // ===== Record Operations ===== /// Add a record to the trail @@ -349,6 +357,7 @@ public fun delete_record( ctx, ); assert!(linked_table::contains(&self.records, sequence_number), ERecordNotFound); + assert_record_access_allowed(self, cap, linked_table::borrow(&self.records, sequence_number)); assert!(!self.is_record_locked(sequence_number, clock), ERecordLocked); let caller = ctx.sender(); @@ -396,6 +405,8 @@ public fun delete_records_batch( let trail_id = self.id(); while (deleted < limit && !self.records.is_empty()) { + let next_sequence_number = option::destroy_some(*linked_table::front(&self.records)); + assert_record_access_allowed(self, cap, linked_table::borrow(&self.records, next_sequence_number)); let (sequence_number, record) = self.records.pop_front(); if (record::tag(&record).is_some()) { diff --git a/audit-trail-move/tests/locking_tests.move b/audit-trail-move/tests/locking_tests.move index 736f55d2..2ea65922 100644 --- a/audit-trail-move/tests/locking_tests.move +++ b/audit-trail-move/tests/locking_tests.move @@ -7,9 +7,11 @@ use audit_trail::{ main::{Self, AuditTrail}, permission, record::{Self, Data}, + record_tags, test_utils::{ Self, setup_test_audit_trail, + setup_test_audit_trail_with_tags, initial_time_for_testing, fetch_capability_trail_and_clock, cleanup_capability_trail_and_clock, @@ -1111,6 +1113,185 @@ fun test_delete_records_batch_bypasses_record_lock() { ts::end(scenario); } +#[test] +fun test_delete_records_batch_with_matching_role_tags() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + { + let locking_config = locking::new( + locking::window_time_based(3600), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail_with_tags( + &mut scenario, + locking_config, + std::option::none(), + vector[string::utf8(b"finance")], + ); + transfer::public_transfer(admin_cap, admin); + }; + + ts::next_tx(&mut scenario, admin); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + let role = string::utf8(b"TaggedDeleteAll"); + let perms = permission::from_vec( + vector[permission::add_record(), permission::delete_all_records()], + ); + + trail + .access_mut() + .create_role( + &admin_cap, + role, + perms, + std::option::some(record_tags::new_role_tags(vector[string::utf8(b"finance")])), + &clock, + ts::ctx(&mut scenario), + ); + + let cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"TaggedDeleteAll"), + &clock, + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(cap, admin); + admin_cap.destroy_for_testing(); + cleanup_trail_and_clock(trail, clock); + }; + + ts::next_tx(&mut scenario, admin); + { + let (cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); + clock.set_for_testing(initial_time_for_testing() + 1000); + + trail.add_record( + &cap, + record::new_text(string::utf8(b"Tagged record")), + std::option::none(), + std::option::some(string::utf8(b"finance")), + &clock, + ts::ctx(&mut scenario), + ); + + let deleted = trail.delete_records_batch(&cap, 10, &clock, ts::ctx(&mut scenario)); + assert!(deleted == 1, 0); + assert!(trail.record_count() == 0, 1); + + cleanup_capability_trail_and_clock(&scenario, cap, trail, clock); + }; + + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = audit_trail::main::ERecordTagNotAllowed)] +fun test_delete_records_batch_requires_matching_role_tags() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + { + let locking_config = locking::new( + locking::window_time_based(3600), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail_with_tags( + &mut scenario, + locking_config, + std::option::none(), + vector[string::utf8(b"finance")], + ); + transfer::public_transfer(admin_cap, admin); + }; + + ts::next_tx(&mut scenario, admin); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + trail + .access_mut() + .create_role( + &admin_cap, + string::utf8(b"TaggedWriter"), + permission::from_vec(vector[permission::add_record()]), + std::option::some(record_tags::new_role_tags(vector[string::utf8(b"finance")])), + &clock, + ts::ctx(&mut scenario), + ); + trail + .access_mut() + .create_role( + &admin_cap, + string::utf8(b"DeleteAllWithoutTags"), + permission::from_vec(vector[permission::delete_all_records()]), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + let tagged_writer_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"TaggedWriter"), + &clock, + ts::ctx(&mut scenario), + ); + let delete_all_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"DeleteAllWithoutTags"), + &clock, + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(tagged_writer_cap, admin); + transfer::public_transfer(delete_all_cap, admin); + admin_cap.destroy_for_testing(); + cleanup_trail_and_clock(trail, clock); + }; + + ts::next_tx(&mut scenario, admin); + { + let delete_all_cap = ts::take_from_sender(&scenario); + let tagged_writer_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + clock.set_for_testing(initial_time_for_testing() + 1000); + + trail.add_record( + &tagged_writer_cap, + record::new_text(string::utf8(b"Tagged record")), + std::option::none(), + std::option::some(string::utf8(b"finance")), + &clock, + ts::ctx(&mut scenario), + ); + + tagged_writer_cap.destroy_for_testing(); + cleanup_capability_trail_and_clock(&scenario, delete_all_cap, trail, clock); + }; + + ts::next_tx(&mut scenario, admin); + { + let delete_all_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + clock.set_for_testing(initial_time_for_testing() + 2000); + + trail.delete_records_batch(&delete_all_cap, 10, &clock, ts::ctx(&mut scenario)); + + cleanup_capability_trail_and_clock(&scenario, delete_all_cap, trail, clock); + }; + + ts::end(scenario); +} + #[test] #[expected_failure(abort_code = audit_trail::role_map::ECapabilityPermissionDenied)] fun test_delete_records_batch_requires_delete_all_records_permission() { diff --git a/audit-trail-move/tests/record_tests.move b/audit-trail-move/tests/record_tests.move index 3d88caa4..ae600261 100644 --- a/audit-trail-move/tests/record_tests.move +++ b/audit-trail-move/tests/record_tests.move @@ -20,7 +20,7 @@ use audit_trail::{ }; use iota::{clock, test_scenario as ts}; use std::string; -use tf_components::timelock; +use tf_components::{capability::Capability, timelock}; // ===== Add Record Tests ===== @@ -173,6 +173,76 @@ fun test_add_tagged_record_with_matching_role_tags() { ts::end(scenario); } +#[test] +fun test_delete_tagged_record_with_matching_role_tags() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + { + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail_with_tags( + &mut scenario, + locking_config, + std::option::none(), + vector[string::utf8(b"finance")], + ); + transfer::public_transfer(admin_cap, admin); + }; + + ts::next_tx(&mut scenario, admin); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + trail.create_role( + &admin_cap, + string::utf8(b"TaggedRecordAdmin"), + permission::record_admin_permissions(), + std::option::some(record_tags::new_role_tags(vector[string::utf8(b"finance")])), + &clock, + ts::ctx(&mut scenario), + ); + + let record_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"TaggedRecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(record_cap, admin); + admin_cap.destroy_for_testing(); + cleanup_trail_and_clock(trail, clock); + }; + + ts::next_tx(&mut scenario, admin); + { + let (record_cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); + clock.set_for_testing(initial_time_for_testing() + 1000); + + trail.add_record( + &record_cap, + record::new_text(string::utf8(b"Tagged record")), + std::option::none(), + std::option::some(string::utf8(b"finance")), + &clock, + ts::ctx(&mut scenario), + ); + + trail.delete_record(&record_cap, 0, &clock, ts::ctx(&mut scenario)); + + assert!(trail.record_count() == 0, 0); + + cleanup_capability_trail_and_clock(&scenario, record_cap, trail, clock); + }; + + ts::end(scenario); +} + #[test] #[expected_failure(abort_code = audit_trail::main::ERecordTagNotAllowed)] fun test_add_tagged_record_requires_matching_role_tags() { @@ -242,6 +312,109 @@ fun test_add_tagged_record_requires_matching_role_tags() { ts::end(scenario); } +#[test] +#[expected_failure(abort_code = audit_trail::main::ERecordTagNotAllowed)] +fun test_delete_tagged_record_requires_matching_role_tags() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + { + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail_with_tags( + &mut scenario, + locking_config, + std::option::none(), + vector[string::utf8(b"finance")], + ); + transfer::public_transfer(admin_cap, admin); + }; + + ts::next_tx(&mut scenario, admin); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + trail + .access_mut() + .create_role( + &admin_cap, + string::utf8(b"TaggedRecordAdmin"), + permission::record_admin_permissions(), + std::option::some(record_tags::new_role_tags(vector[string::utf8(b"finance")])), + &clock, + ts::ctx(&mut scenario), + ); + trail + .access_mut() + .create_role( + &admin_cap, + string::utf8(b"DeleteOnly"), + permission::from_vec(vector[permission::delete_record()]), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + let tagged_record_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"TaggedRecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); + let delete_only_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"DeleteOnly"), + &clock, + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(tagged_record_cap, admin); + transfer::public_transfer(delete_only_cap, admin); + admin_cap.destroy_for_testing(); + cleanup_trail_and_clock(trail, clock); + }; + + ts::next_tx(&mut scenario, admin); + { + let delete_only_cap = ts::take_from_sender(&scenario); + let tagged_record_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + clock.set_for_testing(initial_time_for_testing() + 1000); + + trail.add_record( + &tagged_record_cap, + record::new_text(string::utf8(b"Tagged record")), + std::option::none(), + std::option::some(string::utf8(b"finance")), + &clock, + ts::ctx(&mut scenario), + ); + + tagged_record_cap.destroy_for_testing(); + cleanup_capability_trail_and_clock(&scenario, delete_only_cap, trail, clock); + }; + + ts::next_tx(&mut scenario, admin); + { + let delete_only_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + clock.set_for_testing(initial_time_for_testing() + 2000); + + trail.delete_record(&delete_only_cap, 0, &clock, ts::ctx(&mut scenario)); + + cleanup_capability_trail_and_clock(&scenario, delete_only_cap, trail, clock); + }; + + ts::end(scenario); +} + #[test] #[expected_failure(abort_code = audit_trail::main::ERecordTagNotDefined)] fun test_add_tagged_record_requires_trail_defined_tag() { From f619e8d894d1a0d1b3f970e25424cb0e35e570d3 Mon Sep 17 00:00:00 2001 From: Yasir Date: Thu, 2 Apr 2026 17:26:27 +0300 Subject: [PATCH 133/189] test: add tagged delete authorization regressions --- audit-trail-move/tests/locking_tests.move | 181 --------------------- audit-trail-move/tests/record_tests.move | 177 ++++++++++++++++++++ audit-trail-rs/tests/e2e/records.rs | 190 ++++++++++++++++++++++ 3 files changed, 367 insertions(+), 181 deletions(-) diff --git a/audit-trail-move/tests/locking_tests.move b/audit-trail-move/tests/locking_tests.move index 2ea65922..736f55d2 100644 --- a/audit-trail-move/tests/locking_tests.move +++ b/audit-trail-move/tests/locking_tests.move @@ -7,11 +7,9 @@ use audit_trail::{ main::{Self, AuditTrail}, permission, record::{Self, Data}, - record_tags, test_utils::{ Self, setup_test_audit_trail, - setup_test_audit_trail_with_tags, initial_time_for_testing, fetch_capability_trail_and_clock, cleanup_capability_trail_and_clock, @@ -1113,185 +1111,6 @@ fun test_delete_records_batch_bypasses_record_lock() { ts::end(scenario); } -#[test] -fun test_delete_records_batch_with_matching_role_tags() { - let admin = @0xAD; - let mut scenario = ts::begin(admin); - - { - let locking_config = locking::new( - locking::window_time_based(3600), - timelock::none(), - timelock::none(), - ); - let (admin_cap, _) = setup_test_audit_trail_with_tags( - &mut scenario, - locking_config, - std::option::none(), - vector[string::utf8(b"finance")], - ); - transfer::public_transfer(admin_cap, admin); - }; - - ts::next_tx(&mut scenario, admin); - { - let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); - let role = string::utf8(b"TaggedDeleteAll"); - let perms = permission::from_vec( - vector[permission::add_record(), permission::delete_all_records()], - ); - - trail - .access_mut() - .create_role( - &admin_cap, - role, - perms, - std::option::some(record_tags::new_role_tags(vector[string::utf8(b"finance")])), - &clock, - ts::ctx(&mut scenario), - ); - - let cap = test_utils::new_capability_without_restrictions( - trail.access_mut(), - &admin_cap, - &string::utf8(b"TaggedDeleteAll"), - &clock, - ts::ctx(&mut scenario), - ); - - transfer::public_transfer(cap, admin); - admin_cap.destroy_for_testing(); - cleanup_trail_and_clock(trail, clock); - }; - - ts::next_tx(&mut scenario, admin); - { - let (cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); - clock.set_for_testing(initial_time_for_testing() + 1000); - - trail.add_record( - &cap, - record::new_text(string::utf8(b"Tagged record")), - std::option::none(), - std::option::some(string::utf8(b"finance")), - &clock, - ts::ctx(&mut scenario), - ); - - let deleted = trail.delete_records_batch(&cap, 10, &clock, ts::ctx(&mut scenario)); - assert!(deleted == 1, 0); - assert!(trail.record_count() == 0, 1); - - cleanup_capability_trail_and_clock(&scenario, cap, trail, clock); - }; - - ts::end(scenario); -} - -#[test] -#[expected_failure(abort_code = audit_trail::main::ERecordTagNotAllowed)] -fun test_delete_records_batch_requires_matching_role_tags() { - let admin = @0xAD; - let mut scenario = ts::begin(admin); - - { - let locking_config = locking::new( - locking::window_time_based(3600), - timelock::none(), - timelock::none(), - ); - let (admin_cap, _) = setup_test_audit_trail_with_tags( - &mut scenario, - locking_config, - std::option::none(), - vector[string::utf8(b"finance")], - ); - transfer::public_transfer(admin_cap, admin); - }; - - ts::next_tx(&mut scenario, admin); - { - let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); - - trail - .access_mut() - .create_role( - &admin_cap, - string::utf8(b"TaggedWriter"), - permission::from_vec(vector[permission::add_record()]), - std::option::some(record_tags::new_role_tags(vector[string::utf8(b"finance")])), - &clock, - ts::ctx(&mut scenario), - ); - trail - .access_mut() - .create_role( - &admin_cap, - string::utf8(b"DeleteAllWithoutTags"), - permission::from_vec(vector[permission::delete_all_records()]), - std::option::none(), - &clock, - ts::ctx(&mut scenario), - ); - - let tagged_writer_cap = test_utils::new_capability_without_restrictions( - trail.access_mut(), - &admin_cap, - &string::utf8(b"TaggedWriter"), - &clock, - ts::ctx(&mut scenario), - ); - let delete_all_cap = test_utils::new_capability_without_restrictions( - trail.access_mut(), - &admin_cap, - &string::utf8(b"DeleteAllWithoutTags"), - &clock, - ts::ctx(&mut scenario), - ); - - transfer::public_transfer(tagged_writer_cap, admin); - transfer::public_transfer(delete_all_cap, admin); - admin_cap.destroy_for_testing(); - cleanup_trail_and_clock(trail, clock); - }; - - ts::next_tx(&mut scenario, admin); - { - let delete_all_cap = ts::take_from_sender(&scenario); - let tagged_writer_cap = ts::take_from_sender(&scenario); - let mut trail = ts::take_shared>(&scenario); - let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); - clock.set_for_testing(initial_time_for_testing() + 1000); - - trail.add_record( - &tagged_writer_cap, - record::new_text(string::utf8(b"Tagged record")), - std::option::none(), - std::option::some(string::utf8(b"finance")), - &clock, - ts::ctx(&mut scenario), - ); - - tagged_writer_cap.destroy_for_testing(); - cleanup_capability_trail_and_clock(&scenario, delete_all_cap, trail, clock); - }; - - ts::next_tx(&mut scenario, admin); - { - let delete_all_cap = ts::take_from_sender(&scenario); - let mut trail = ts::take_shared>(&scenario); - let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); - clock.set_for_testing(initial_time_for_testing() + 2000); - - trail.delete_records_batch(&delete_all_cap, 10, &clock, ts::ctx(&mut scenario)); - - cleanup_capability_trail_and_clock(&scenario, delete_all_cap, trail, clock); - }; - - ts::end(scenario); -} - #[test] #[expected_failure(abort_code = audit_trail::role_map::ECapabilityPermissionDenied)] fun test_delete_records_batch_requires_delete_all_records_permission() { diff --git a/audit-trail-move/tests/record_tests.move b/audit-trail-move/tests/record_tests.move index ae600261..153fc535 100644 --- a/audit-trail-move/tests/record_tests.move +++ b/audit-trail-move/tests/record_tests.move @@ -243,6 +243,80 @@ fun test_delete_tagged_record_with_matching_role_tags() { ts::end(scenario); } +#[test] +fun test_delete_records_batch_with_matching_role_tags() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + { + let locking_config = locking::new( + locking::window_time_based(3600), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail_with_tags( + &mut scenario, + locking_config, + std::option::none(), + vector[string::utf8(b"finance")], + ); + transfer::public_transfer(admin_cap, admin); + }; + + ts::next_tx(&mut scenario, admin); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + trail + .access_mut() + .create_role( + &admin_cap, + string::utf8(b"TaggedDeleteAll"), + permission::from_vec( + vector[permission::add_record(), permission::delete_all_records()], + ), + std::option::some(record_tags::new_role_tags(vector[string::utf8(b"finance")])), + &clock, + ts::ctx(&mut scenario), + ); + + let cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"TaggedDeleteAll"), + &clock, + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(cap, admin); + admin_cap.destroy_for_testing(); + cleanup_trail_and_clock(trail, clock); + }; + + ts::next_tx(&mut scenario, admin); + { + let (cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); + clock.set_for_testing(initial_time_for_testing() + 1000); + + trail.add_record( + &cap, + record::new_text(string::utf8(b"Tagged record")), + std::option::none(), + std::option::some(string::utf8(b"finance")), + &clock, + ts::ctx(&mut scenario), + ); + + let deleted = trail.delete_records_batch(&cap, 10, &clock, ts::ctx(&mut scenario)); + assert!(deleted == 1, 0); + assert!(trail.record_count() == 0, 1); + + cleanup_capability_trail_and_clock(&scenario, cap, trail, clock); + }; + + ts::end(scenario); +} + #[test] #[expected_failure(abort_code = audit_trail::main::ERecordTagNotAllowed)] fun test_add_tagged_record_requires_matching_role_tags() { @@ -415,6 +489,109 @@ fun test_delete_tagged_record_requires_matching_role_tags() { ts::end(scenario); } +#[test] +#[expected_failure(abort_code = audit_trail::main::ERecordTagNotAllowed)] +fun test_delete_records_batch_requires_matching_role_tags() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + { + let locking_config = locking::new( + locking::window_time_based(3600), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail_with_tags( + &mut scenario, + locking_config, + std::option::none(), + vector[string::utf8(b"finance")], + ); + transfer::public_transfer(admin_cap, admin); + }; + + ts::next_tx(&mut scenario, admin); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + trail + .access_mut() + .create_role( + &admin_cap, + string::utf8(b"TaggedWriter"), + permission::from_vec(vector[permission::add_record()]), + std::option::some(record_tags::new_role_tags(vector[string::utf8(b"finance")])), + &clock, + ts::ctx(&mut scenario), + ); + trail + .access_mut() + .create_role( + &admin_cap, + string::utf8(b"DeleteAllWithoutTags"), + permission::from_vec(vector[permission::delete_all_records()]), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + let tagged_writer_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"TaggedWriter"), + &clock, + ts::ctx(&mut scenario), + ); + let delete_all_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"DeleteAllWithoutTags"), + &clock, + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(tagged_writer_cap, admin); + transfer::public_transfer(delete_all_cap, admin); + admin_cap.destroy_for_testing(); + cleanup_trail_and_clock(trail, clock); + }; + + ts::next_tx(&mut scenario, admin); + { + let delete_all_cap = ts::take_from_sender(&scenario); + let tagged_writer_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + clock.set_for_testing(initial_time_for_testing() + 1000); + + trail.add_record( + &tagged_writer_cap, + record::new_text(string::utf8(b"Tagged record")), + std::option::none(), + std::option::some(string::utf8(b"finance")), + &clock, + ts::ctx(&mut scenario), + ); + + tagged_writer_cap.destroy_for_testing(); + cleanup_capability_trail_and_clock(&scenario, delete_all_cap, trail, clock); + }; + + ts::next_tx(&mut scenario, admin); + { + let delete_all_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + clock.set_for_testing(initial_time_for_testing() + 2000); + + trail.delete_records_batch(&delete_all_cap, 10, &clock, ts::ctx(&mut scenario)); + + cleanup_capability_trail_and_clock(&scenario, delete_all_cap, trail, clock); + }; + + ts::end(scenario); +} + #[test] #[expected_failure(abort_code = audit_trail::main::ERecordTagNotDefined)] fun test_add_tagged_record_requires_trail_defined_tag() { diff --git a/audit-trail-rs/tests/e2e/records.rs b/audit-trail-rs/tests/e2e/records.rs index 7aeddbe3..439c54c7 100644 --- a/audit-trail-rs/tests/e2e/records.rs +++ b/audit-trail-rs/tests/e2e/records.rs @@ -395,6 +395,101 @@ async fn delete_record_removes_entry_and_keeps_sequence_monotonic() -> anyhow::R Ok(()) } +#[tokio::test] +async fn delete_tagged_record_requires_matching_role_tag_access() -> anyhow::Result<()> { + let admin = get_funded_test_client().await?; + let writer = get_funded_test_client().await?; + let deleter = get_funded_test_client().await?; + let trail_id = admin + .create_test_trail_with_tags(Data::text("delete-tagged-deny"), ["finance"]) + .await?; + + admin + .create_role( + trail_id, + "TaggedWriter", + [Permission::AddRecord], + Some(RoleTags::new(["finance"])), + ) + .await?; + admin + .create_role(trail_id, "DeleteOnly", [Permission::DeleteRecord], None) + .await?; + admin + .issue_cap( + trail_id, + "TaggedWriter", + CapabilityIssueOptions { + issued_to: Some(writer.sender_address()), + ..CapabilityIssueOptions::default() + }, + ) + .await?; + admin + .issue_cap( + trail_id, + "DeleteOnly", + CapabilityIssueOptions { + issued_to: Some(deleter.sender_address()), + ..CapabilityIssueOptions::default() + }, + ) + .await?; + + writer + .trail(trail_id) + .records() + .add(Data::text("tagged record"), None, Some("finance".to_string())) + .build_and_execute(&writer) + .await?; + + let denied = deleter + .trail(trail_id) + .records() + .delete(1) + .build_and_execute(&deleter) + .await; + + assert!(denied.is_err(), "tagged deletes should require matching role tag access"); + assert_eq!(admin.trail(trail_id).records().record_count().await?, 2); + assert_eq!(admin.trail(trail_id).records().get(1).await?.tag.as_deref(), Some("finance")); + + Ok(()) +} + +#[tokio::test] +async fn delete_tagged_record_with_matching_role_tag_access_succeeds() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client + .create_test_trail_with_tags(Data::text("delete-tagged-allow"), ["finance"]) + .await?; + let records = client.trail(trail_id).records(); + + client + .create_role( + trail_id, + "TaggedRecordAdmin", + [Permission::AddRecord, Permission::DeleteRecord], + Some(RoleTags::new(["finance"])), + ) + .await?; + client + .issue_cap(trail_id, "TaggedRecordAdmin", CapabilityIssueOptions::default()) + .await?; + + records + .add(Data::text("tagged record"), None, Some("finance".to_string())) + .build_and_execute(&client) + .await?; + + let deleted = records.delete(1).build_and_execute(&client).await?.output; + assert_eq!(deleted.sequence_number, 1); + assert_eq!(records.record_count().await?, 1); + assert!(records.get(1).await.is_err()); + + Ok(()) +} + #[tokio::test] async fn delete_record_requires_delete_permission() -> anyhow::Result<()> { let client = get_funded_test_client().await?; @@ -575,6 +670,101 @@ async fn delete_records_batch_respects_limit_and_deletes_oldest_first() -> anyho Ok(()) } +#[tokio::test] +async fn delete_records_batch_requires_matching_role_tag_access() -> anyhow::Result<()> { + let admin = get_funded_test_client().await?; + let writer = get_funded_test_client().await?; + let deleter = get_funded_test_client().await?; + let trail_id = admin + .create_test_trail_with_tags(Data::text("batch-delete-tagged-deny"), ["finance"]) + .await?; + + admin + .create_role( + trail_id, + "TaggedWriter", + [Permission::AddRecord], + Some(RoleTags::new(["finance"])), + ) + .await?; + admin + .create_role(trail_id, "DeleteAllWithoutTags", [Permission::DeleteAllRecords], None) + .await?; + admin + .issue_cap( + trail_id, + "TaggedWriter", + CapabilityIssueOptions { + issued_to: Some(writer.sender_address()), + ..CapabilityIssueOptions::default() + }, + ) + .await?; + admin + .issue_cap( + trail_id, + "DeleteAllWithoutTags", + CapabilityIssueOptions { + issued_to: Some(deleter.sender_address()), + ..CapabilityIssueOptions::default() + }, + ) + .await?; + + writer + .trail(trail_id) + .records() + .add(Data::text("tagged record"), None, Some("finance".to_string())) + .build_and_execute(&writer) + .await?; + + let denied = deleter + .trail(trail_id) + .records() + .delete_records_batch(10) + .build_and_execute(&deleter) + .await; + + assert!(denied.is_err(), "tagged batch deletes should require matching role tag access"); + assert_eq!(admin.trail(trail_id).records().record_count().await?, 2); + assert_eq!(admin.trail(trail_id).records().get(1).await?.tag.as_deref(), Some("finance")); + + Ok(()) +} + +#[tokio::test] +async fn delete_records_batch_with_matching_role_tag_access_succeeds() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let trail_id = client + .create_test_trail_with_tags(Data::text("batch-delete-tagged-allow"), ["finance"]) + .await?; + let records = client.trail(trail_id).records(); + + client + .create_role( + trail_id, + "TaggedDeleteAll", + [Permission::AddRecord, Permission::DeleteAllRecords], + Some(RoleTags::new(["finance"])), + ) + .await?; + client + .issue_cap(trail_id, "TaggedDeleteAll", CapabilityIssueOptions::default()) + .await?; + + records + .add(Data::text("tagged record"), None, Some("finance".to_string())) + .build_and_execute(&client) + .await?; + + let deleted = records.delete_records_batch(10).build_and_execute(&client).await?.output; + assert_eq!(deleted, 1); + assert_eq!(records.record_count().await?, 1); + assert!(records.get(1).await.is_err()); + + Ok(()) +} + #[tokio::test] async fn list_and_pagination_support_sparse_sequence_numbers() -> anyhow::Result<()> { let client = get_funded_test_client().await?; From 1f9439ee5ef99bff099ae426b05569c6cdfcb8b8 Mon Sep 17 00:00:00 2001 From: Yasir Date: Thu, 2 Apr 2026 17:34:37 +0300 Subject: [PATCH 134/189] test: move audit trail CRUD coverage to record suite --- audit-trail-move/tests/locking_tests.move | 317 ---------------------- audit-trail-move/tests/record_tests.move | 296 ++++++++++++++++++++ audit-trail-rs/tests/e2e/records.rs | 110 ++++---- 3 files changed, 343 insertions(+), 380 deletions(-) diff --git a/audit-trail-move/tests/locking_tests.move b/audit-trail-move/tests/locking_tests.move index 736f55d2..ee2767f3 100644 --- a/audit-trail-move/tests/locking_tests.move +++ b/audit-trail-move/tests/locking_tests.move @@ -562,98 +562,6 @@ fun test_update_delete_record_window_permission_denied() { ts::end(scenario); } -#[test] -fun test_delete_record_after_time_lock_expires() { - let admin = @0xAD; - let mut scenario = ts::begin(admin); - - // Create trail with 1 hour time-based locking and initial record - { - let locking_config = locking::new( - locking::window_time_based(3600), - timelock::none(), - timelock::none(), - ); // 1 hour = 3600 seconds - let (admin_cap, _) = setup_test_audit_trail( - &mut scenario, - locking_config, - std::option::some(record::new_text(string::utf8(b"Locked record"))), - ); - transfer::public_transfer(admin_cap, admin); - }; - - // Create RecordAdmin role - ts::next_tx(&mut scenario, admin); - { - let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); - - trail - .access_mut() - .create_role( - &admin_cap, - string::utf8(b"RecordAdmin"), - permission::record_admin_permissions(), - std::option::none(), - &clock, - ts::ctx(&mut scenario), - ); - - let record_cap = test_utils::new_capability_without_restrictions( - trail.access_mut(), - &admin_cap, - &string::utf8(b"RecordAdmin"), - &clock, - ts::ctx(&mut scenario), - ); - - transfer::public_transfer(record_cap, admin); - admin_cap.destroy_for_testing(); - cleanup_trail_and_clock(trail, clock); - }; - - // Test boundary: exactly at lock expiry (should still be locked) - ts::next_tx(&mut scenario, admin); - { - let trail = ts::take_shared>(&scenario); - let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); - - // Exactly at 1 hour mark - record age equals time window (edge case) - // So at exactly the boundary, record should be UNLOCKED - clock.set_for_testing(initial_time_for_testing() + 3600 * 1000); - assert!(!trail.is_record_locked(0, &clock), 0); - - clock::destroy_for_testing(clock); - ts::return_shared(trail); - }; - - // Delete record after time lock expires - ts::next_tx(&mut scenario, admin); - { - let mut trail = ts::take_shared>(&scenario); - let record_cap = ts::take_from_sender(&scenario); - - // 1 hour + 1 second after creation - clearly past the lock window - let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); - clock.set_for_testing(initial_time_for_testing() + 3601 * 1000); - - // Verify record exists and is unlocked - assert!(trail.has_record(0), 1); - assert!(!trail.is_record_locked(0, &clock), 2); - - // Delete should succeed - trail.delete_record(&record_cap, 0, &clock, ts::ctx(&mut scenario)); - - // Verify record was deleted - assert!(!trail.has_record(0), 3); - - clock::destroy_for_testing(clock); - record_cap.destroy_for_testing(); - ts::return_shared(trail); - }; - - ts::end(scenario); -} - #[test] fun test_time_lock_boundary_just_before_expiry() { let admin = @0xAD; @@ -954,231 +862,6 @@ fun test_time_based_locking_still_locked_before_expiry() { ts::end(scenario); } -#[test] -fun test_count_based_locking_old_record_can_delete() { - let admin = @0xAD; - let mut scenario = ts::begin(admin); - - // Create trail with count-based (last 2) locking - { - let locking_config = locking::new( - locking::window_count_based(2), - timelock::none(), - timelock::none(), - ); - let (admin_cap, _) = setup_test_audit_trail( - &mut scenario, - locking_config, - std::option::none(), - ); - transfer::public_transfer(admin_cap, admin); - }; - - // Create RecordAdmin role and add records - ts::next_tx(&mut scenario, admin); - { - let (admin_cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); - - trail - .access_mut() - .create_role( - &admin_cap, - string::utf8(b"RecordAdmin"), - permission::record_admin_permissions(), - std::option::none(), - &clock, - ts::ctx(&mut scenario), - ); - - let record_cap = test_utils::new_capability_without_restrictions( - trail.access_mut(), - &admin_cap, - &string::utf8(b"RecordAdmin"), - &clock, - ts::ctx(&mut scenario), - ); - - // Add 5 records - clock.set_for_testing(initial_time_for_testing() + 1000); - - let mut i = 0u64; - while (i < 5) { - trail.add_record( - &record_cap, - record::new_text(string::utf8(b"Record")), - std::option::none(), - std::option::none(), - &clock, - ts::ctx(&mut scenario), - ); - i = i + 1; - }; - - transfer::public_transfer(record_cap, admin); - admin_cap.destroy_for_testing(); - cleanup_trail_and_clock(trail, clock); - }; - - // Test: Old record is outside count window and can be deleted - ts::next_tx(&mut scenario, admin); - { - let mut trail = ts::take_shared>(&scenario); - let record_cap = ts::take_from_sender(&scenario); - - let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); - - // Record 0 is not in last 2 - count lock condition satisfied - clock.set_for_testing(initial_time_for_testing() + 7200 * 1000); - - // Verify record 0 is unlocked - assert!(!trail.is_record_locked(0, &clock), 0); - assert!(trail.has_record(0), 1); - - // Delete should succeed - trail.delete_record(&record_cap, 0, &clock, ts::ctx(&mut scenario)); - - // Verify deletion - assert!(!trail.has_record(0), 2); - - clock::destroy_for_testing(clock); - record_cap.destroy_for_testing(); - ts::return_shared(trail); - }; - - ts::end(scenario); -} - -#[test] -fun test_delete_records_batch_bypasses_record_lock() { - let admin = @0xAD; - let mut scenario = ts::begin(admin); - - // Create trail with 1 hour delete lock and an initial record. - { - let locking_config = locking::new( - locking::window_time_based(3600), - timelock::none(), - timelock::none(), - ); - let (admin_cap, _) = setup_test_audit_trail( - &mut scenario, - locking_config, - std::option::some(record::new_text(string::utf8(b"Locked"))), - ); - transfer::public_transfer(admin_cap, admin); - }; - - ts::next_tx(&mut scenario, admin); - { - let (admin_cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); - let delete_all_role = string::utf8(b"DeleteAllRecordsAdmin"); - let delete_all_perms = permission::from_vec(vector[permission::delete_all_records()]); - - trail - .access_mut() - .create_role( - &admin_cap, - delete_all_role, - delete_all_perms, - std::option::none(), - &clock, - ts::ctx(&mut scenario), - ); - - let delete_all_cap = test_utils::new_capability_without_restrictions( - trail.access_mut(), - &admin_cap, - &string::utf8(b"DeleteAllRecordsAdmin"), - &clock, - ts::ctx(&mut scenario), - ); - - // Stay inside the lock window; direct delete_record would fail. - clock.set_for_testing(initial_time_for_testing() + 1000); - let deleted = trail.delete_records_batch( - &delete_all_cap, - 10, - &clock, - ts::ctx(&mut scenario), - ); - assert!(deleted == 1, 0); - assert!(trail.record_count() == 0, 1); - - delete_all_cap.destroy_for_testing(); - cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); - }; - - ts::end(scenario); -} - -#[test] -#[expected_failure(abort_code = audit_trail::role_map::ECapabilityPermissionDenied)] -fun test_delete_records_batch_requires_delete_all_records_permission() { - let admin = @0xAD; - let mut scenario = ts::begin(admin); - - { - let locking_config = locking::new( - locking::window_none(), - timelock::none(), - timelock::none(), - ); - let (admin_cap, _) = setup_test_audit_trail( - &mut scenario, - locking_config, - std::option::some(record::new_text(string::utf8(b"Record"))), - ); - transfer::public_transfer(admin_cap, admin); - }; - - // Create a role that has DeleteAuditTrail but NOT DeleteAllRecords. - ts::next_tx(&mut scenario, admin); - { - let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); - - let perms = permission::from_vec(vector[permission::delete_audit_trail()]); - trail - .access_mut() - .create_role( - &admin_cap, - string::utf8(b"TrailDeleteOnly"), - perms, - std::option::none(), - &clock, - ts::ctx(&mut scenario), - ); - - let delete_only_cap = test_utils::new_capability_without_restrictions( - trail.access_mut(), - &admin_cap, - &string::utf8(b"TrailDeleteOnly"), - &clock, - ts::ctx(&mut scenario), - ); - - transfer::public_transfer(delete_only_cap, admin); - admin_cap.destroy_for_testing(); - cleanup_trail_and_clock(trail, clock); - }; - - // Must fail: delete_records_batch requires DeleteAllRecords specifically. - ts::next_tx(&mut scenario, admin); - { - let delete_only_cap = ts::take_from_sender(&scenario); - let mut trail = ts::take_shared>(&scenario); - let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); - clock.set_for_testing(initial_time_for_testing() + 1000); - - trail.delete_records_batch(&delete_only_cap, 10, &clock, ts::ctx(&mut scenario)); - - clock::destroy_for_testing(clock); - delete_only_cap.destroy_for_testing(); - ts::return_shared(trail); - }; - - ts::end(scenario); -} - #[test] #[expected_failure(abort_code = audit_trail::main::ETrailNotEmpty)] fun test_delete_audit_trail_fails_while_not_empty() { diff --git a/audit-trail-move/tests/record_tests.move b/audit-trail-move/tests/record_tests.move index 153fc535..aaf924e6 100644 --- a/audit-trail-move/tests/record_tests.move +++ b/audit-trail-move/tests/record_tests.move @@ -1235,6 +1235,302 @@ fun test_delete_record_count_locked() { ts::end(scenario); } +#[test] +fun test_delete_record_after_time_lock_expires() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + { + let locking_config = locking::new( + locking::window_time_based(3600), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::some(record::new_text(string::utf8(b"Locked record"))), + ); + transfer::public_transfer(admin_cap, admin); + }; + + ts::next_tx(&mut scenario, admin); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + trail + .access_mut() + .create_role( + &admin_cap, + string::utf8(b"RecordAdmin"), + permission::record_admin_permissions(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + let record_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(record_cap, admin); + admin_cap.destroy_for_testing(); + cleanup_trail_and_clock(trail, clock); + }; + + ts::next_tx(&mut scenario, admin); + { + let trail = ts::take_shared>(&scenario); + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + + clock.set_for_testing(initial_time_for_testing() + 3600 * 1000); + assert!(!trail.is_record_locked(0, &clock), 0); + + clock::destroy_for_testing(clock); + ts::return_shared(trail); + }; + + ts::next_tx(&mut scenario, admin); + { + let mut trail = ts::take_shared>(&scenario); + let record_cap = ts::take_from_sender(&scenario); + + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + clock.set_for_testing(initial_time_for_testing() + 3601 * 1000); + + assert!(trail.has_record(0), 1); + assert!(!trail.is_record_locked(0, &clock), 2); + + trail.delete_record(&record_cap, 0, &clock, ts::ctx(&mut scenario)); + + assert!(!trail.has_record(0), 3); + + clock::destroy_for_testing(clock); + record_cap.destroy_for_testing(); + ts::return_shared(trail); + }; + + ts::end(scenario); +} + +#[test] +fun test_count_based_locking_old_record_can_delete() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + { + let locking_config = locking::new( + locking::window_count_based(2), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::none(), + ); + transfer::public_transfer(admin_cap, admin); + }; + + ts::next_tx(&mut scenario, admin); + { + let (admin_cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); + + trail + .access_mut() + .create_role( + &admin_cap, + string::utf8(b"RecordAdmin"), + permission::record_admin_permissions(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + let record_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); + + clock.set_for_testing(initial_time_for_testing() + 1000); + + let mut i = 0u64; + while (i < 5) { + trail.add_record( + &record_cap, + record::new_text(string::utf8(b"Record")), + std::option::none(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + i = i + 1; + }; + + transfer::public_transfer(record_cap, admin); + admin_cap.destroy_for_testing(); + cleanup_trail_and_clock(trail, clock); + }; + + ts::next_tx(&mut scenario, admin); + { + let mut trail = ts::take_shared>(&scenario); + let record_cap = ts::take_from_sender(&scenario); + + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + clock.set_for_testing(initial_time_for_testing() + 7200 * 1000); + + assert!(!trail.is_record_locked(0, &clock), 0); + assert!(trail.has_record(0), 1); + + trail.delete_record(&record_cap, 0, &clock, ts::ctx(&mut scenario)); + + assert!(!trail.has_record(0), 2); + + clock::destroy_for_testing(clock); + record_cap.destroy_for_testing(); + ts::return_shared(trail); + }; + + ts::end(scenario); +} + +// ===== Delete Records Batch Tests ===== + +#[test] +fun test_delete_records_batch_bypasses_record_lock() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + { + let locking_config = locking::new( + locking::window_time_based(3600), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::some(record::new_text(string::utf8(b"Locked"))), + ); + transfer::public_transfer(admin_cap, admin); + }; + + ts::next_tx(&mut scenario, admin); + { + let (admin_cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); + let delete_all_role = string::utf8(b"DeleteAllRecordsAdmin"); + let delete_all_perms = permission::from_vec(vector[permission::delete_all_records()]); + + trail + .access_mut() + .create_role( + &admin_cap, + delete_all_role, + delete_all_perms, + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + let delete_all_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"DeleteAllRecordsAdmin"), + &clock, + ts::ctx(&mut scenario), + ); + + clock.set_for_testing(initial_time_for_testing() + 1000); + let deleted = trail.delete_records_batch( + &delete_all_cap, + 10, + &clock, + ts::ctx(&mut scenario), + ); + assert!(deleted == 1, 0); + assert!(trail.record_count() == 0, 1); + + delete_all_cap.destroy_for_testing(); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + }; + + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = audit_trail::role_map::ECapabilityPermissionDenied)] +fun test_delete_records_batch_requires_delete_all_records_permission() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + { + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::some(record::new_text(string::utf8(b"Record"))), + ); + transfer::public_transfer(admin_cap, admin); + }; + + ts::next_tx(&mut scenario, admin); + { + let (admin_cap, mut trail, clock) = fetch_capability_trail_and_clock(&mut scenario); + + let perms = permission::from_vec(vector[permission::delete_audit_trail()]); + trail + .access_mut() + .create_role( + &admin_cap, + string::utf8(b"TrailDeleteOnly"), + perms, + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + let delete_only_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"TrailDeleteOnly"), + &clock, + ts::ctx(&mut scenario), + ); + + transfer::public_transfer(delete_only_cap, admin); + admin_cap.destroy_for_testing(); + cleanup_trail_and_clock(trail, clock); + }; + + ts::next_tx(&mut scenario, admin); + { + let delete_only_cap = ts::take_from_sender(&scenario); + let mut trail = ts::take_shared>(&scenario); + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + clock.set_for_testing(initial_time_for_testing() + 1000); + + trail.delete_records_batch(&delete_only_cap, 10, &clock, ts::ctx(&mut scenario)); + + clock::destroy_for_testing(clock); + delete_only_cap.destroy_for_testing(); + ts::return_shared(trail); + }; + + ts::end(scenario); +} + // ===== Query Function Tests ===== #[test] diff --git a/audit-trail-rs/tests/e2e/records.rs b/audit-trail-rs/tests/e2e/records.rs index 439c54c7..32559f75 100644 --- a/audit-trail-rs/tests/e2e/records.rs +++ b/audit-trail-rs/tests/e2e/records.rs @@ -397,14 +397,12 @@ async fn delete_record_removes_entry_and_keeps_sequence_monotonic() -> anyhow::R #[tokio::test] async fn delete_tagged_record_requires_matching_role_tag_access() -> anyhow::Result<()> { - let admin = get_funded_test_client().await?; - let writer = get_funded_test_client().await?; - let deleter = get_funded_test_client().await?; - let trail_id = admin + let client = get_funded_test_client().await?; + let trail_id = client .create_test_trail_with_tags(Data::text("delete-tagged-deny"), ["finance"]) .await?; - admin + client .create_role( trail_id, "TaggedWriter", @@ -412,47 +410,39 @@ async fn delete_tagged_record_requires_matching_role_tag_access() -> anyhow::Res Some(RoleTags::new(["finance"])), ) .await?; - admin + client .create_role(trail_id, "DeleteOnly", [Permission::DeleteRecord], None) .await?; - admin - .issue_cap( - trail_id, - "TaggedWriter", - CapabilityIssueOptions { - issued_to: Some(writer.sender_address()), - ..CapabilityIssueOptions::default() - }, - ) + client + .issue_cap(trail_id, "TaggedWriter", CapabilityIssueOptions::default()) .await?; - admin - .issue_cap( - trail_id, - "DeleteOnly", - CapabilityIssueOptions { - issued_to: Some(deleter.sender_address()), - ..CapabilityIssueOptions::default() - }, - ) + client + .issue_cap(trail_id, "DeleteOnly", CapabilityIssueOptions::default()) .await?; - writer + client .trail(trail_id) .records() .add(Data::text("tagged record"), None, Some("finance".to_string())) - .build_and_execute(&writer) + .build_and_execute(&client) .await?; - let denied = deleter + let denied = client .trail(trail_id) .records() .delete(1) - .build_and_execute(&deleter) + .build_and_execute(&client) .await; - assert!(denied.is_err(), "tagged deletes should require matching role tag access"); - assert_eq!(admin.trail(trail_id).records().record_count().await?, 2); - assert_eq!(admin.trail(trail_id).records().get(1).await?.tag.as_deref(), Some("finance")); + assert!( + denied.is_err(), + "tagged deletes should require matching role tag access" + ); + assert_eq!(client.trail(trail_id).records().record_count().await?, 2); + assert_eq!( + client.trail(trail_id).records().get(1).await?.tag.as_deref(), + Some("finance") + ); Ok(()) } @@ -672,14 +662,12 @@ async fn delete_records_batch_respects_limit_and_deletes_oldest_first() -> anyho #[tokio::test] async fn delete_records_batch_requires_matching_role_tag_access() -> anyhow::Result<()> { - let admin = get_funded_test_client().await?; - let writer = get_funded_test_client().await?; - let deleter = get_funded_test_client().await?; - let trail_id = admin + let client = get_funded_test_client().await?; + let trail_id = client .create_test_trail_with_tags(Data::text("batch-delete-tagged-deny"), ["finance"]) .await?; - admin + client .create_role( trail_id, "TaggedWriter", @@ -687,47 +675,39 @@ async fn delete_records_batch_requires_matching_role_tag_access() -> anyhow::Res Some(RoleTags::new(["finance"])), ) .await?; - admin + client .create_role(trail_id, "DeleteAllWithoutTags", [Permission::DeleteAllRecords], None) .await?; - admin - .issue_cap( - trail_id, - "TaggedWriter", - CapabilityIssueOptions { - issued_to: Some(writer.sender_address()), - ..CapabilityIssueOptions::default() - }, - ) + client + .issue_cap(trail_id, "TaggedWriter", CapabilityIssueOptions::default()) .await?; - admin - .issue_cap( - trail_id, - "DeleteAllWithoutTags", - CapabilityIssueOptions { - issued_to: Some(deleter.sender_address()), - ..CapabilityIssueOptions::default() - }, - ) + client + .issue_cap(trail_id, "DeleteAllWithoutTags", CapabilityIssueOptions::default()) .await?; - writer + client .trail(trail_id) .records() .add(Data::text("tagged record"), None, Some("finance".to_string())) - .build_and_execute(&writer) + .build_and_execute(&client) .await?; - let denied = deleter + let denied = client .trail(trail_id) .records() .delete_records_batch(10) - .build_and_execute(&deleter) + .build_and_execute(&client) .await; - assert!(denied.is_err(), "tagged batch deletes should require matching role tag access"); - assert_eq!(admin.trail(trail_id).records().record_count().await?, 2); - assert_eq!(admin.trail(trail_id).records().get(1).await?.tag.as_deref(), Some("finance")); + assert!( + denied.is_err(), + "tagged batch deletes should require matching role tag access" + ); + assert_eq!(client.trail(trail_id).records().record_count().await?, 2); + assert_eq!( + client.trail(trail_id).records().get(1).await?.tag.as_deref(), + Some("finance") + ); Ok(()) } @@ -757,7 +737,11 @@ async fn delete_records_batch_with_matching_role_tag_access_succeeds() -> anyhow .build_and_execute(&client) .await?; - let deleted = records.delete_records_batch(10).build_and_execute(&client).await?.output; + let deleted = records + .delete_records_batch(10) + .build_and_execute(&client) + .await? + .output; assert_eq!(deleted, 1); assert_eq!(records.record_count().await?, 1); assert!(records.get(1).await.is_err()); From 9de456e606046070b6173eee8263886887c4cd2d Mon Sep 17 00:00:00 2001 From: Yasir Date: Thu, 2 Apr 2026 17:51:46 +0300 Subject: [PATCH 135/189] refactor: inline audit trail tag access checks --- audit-trail-move/sources/audit_trail.move | 20 +-- audit-trail-move/tests/locking_tests.move | 146 ++++++++++++++++++++++ audit-trail-move/tests/record_tests.move | 146 ---------------------- 3 files changed, 156 insertions(+), 156 deletions(-) diff --git a/audit-trail-move/sources/audit_trail.move b/audit-trail-move/sources/audit_trail.move index 8bfd33c4..46d1132f 100644 --- a/audit-trail-move/sources/audit_trail.move +++ b/audit-trail-move/sources/audit_trail.move @@ -272,14 +272,6 @@ fun assert_record_tag_allowed( assert!(record_tags::role_allows(&self.roles, cap, requested_tag), ERecordTagNotAllowed); } -fun assert_record_access_allowed( - self: &AuditTrail, - cap: &Capability, - record: &Record, -) { - assert_record_tag_allowed(self, cap, record::tag(record)); -} - // ===== Record Operations ===== /// Add a record to the trail @@ -357,7 +349,11 @@ public fun delete_record( ctx, ); assert!(linked_table::contains(&self.records, sequence_number), ERecordNotFound); - assert_record_access_allowed(self, cap, linked_table::borrow(&self.records, sequence_number)); + assert_record_tag_allowed( + self, + cap, + record::tag(linked_table::borrow(&self.records, sequence_number)), + ); assert!(!self.is_record_locked(sequence_number, clock), ERecordLocked); let caller = ctx.sender(); @@ -406,7 +402,11 @@ public fun delete_records_batch( while (deleted < limit && !self.records.is_empty()) { let next_sequence_number = option::destroy_some(*linked_table::front(&self.records)); - assert_record_access_allowed(self, cap, linked_table::borrow(&self.records, next_sequence_number)); + assert_record_tag_allowed( + self, + cap, + record::tag(linked_table::borrow(&self.records, next_sequence_number)), + ); let (sequence_number, record) = self.records.pop_front(); if (record::tag(&record).is_some()) { diff --git a/audit-trail-move/tests/locking_tests.move b/audit-trail-move/tests/locking_tests.move index ee2767f3..5ccb4e14 100644 --- a/audit-trail-move/tests/locking_tests.move +++ b/audit-trail-move/tests/locking_tests.move @@ -777,6 +777,91 @@ fun test_count_based_locking_last_records_remain_locked() { ts::end(scenario); } +#[test] +fun test_count_based_locking_old_record_can_delete() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + { + let locking_config = locking::new( + locking::window_count_based(2), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::none(), + ); + transfer::public_transfer(admin_cap, admin); + }; + + ts::next_tx(&mut scenario, admin); + { + let (admin_cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); + + trail + .access_mut() + .create_role( + &admin_cap, + string::utf8(b"RecordAdmin"), + permission::record_admin_permissions(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + let record_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"RecordAdmin"), + &clock, + ts::ctx(&mut scenario), + ); + + clock.set_for_testing(initial_time_for_testing() + 1000); + + let mut i = 0u64; + while (i < 5) { + trail.add_record( + &record_cap, + record::new_text(string::utf8(b"Record")), + std::option::none(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + i = i + 1; + }; + + transfer::public_transfer(record_cap, admin); + admin_cap.destroy_for_testing(); + cleanup_trail_and_clock(trail, clock); + }; + + ts::next_tx(&mut scenario, admin); + { + let mut trail = ts::take_shared>(&scenario); + let record_cap = ts::take_from_sender(&scenario); + + let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); + clock.set_for_testing(initial_time_for_testing() + 7200 * 1000); + + assert!(!trail.is_record_locked(0, &clock), 0); + assert!(trail.has_record(0), 1); + + trail.delete_record(&record_cap, 0, &clock, ts::ctx(&mut scenario)); + + assert!(!trail.has_record(0), 2); + + clock::destroy_for_testing(clock); + record_cap.destroy_for_testing(); + ts::return_shared(trail); + }; + + ts::end(scenario); +} + #[test] fun test_time_based_locking_still_locked_before_expiry() { let admin = @0xAD; @@ -862,6 +947,67 @@ fun test_time_based_locking_still_locked_before_expiry() { ts::end(scenario); } +#[test] +fun test_delete_records_batch_bypasses_record_lock() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + { + let locking_config = locking::new( + locking::window_time_based(3600), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::some(record::new_text(string::utf8(b"Locked"))), + ); + transfer::public_transfer(admin_cap, admin); + }; + + ts::next_tx(&mut scenario, admin); + { + let (admin_cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); + let delete_all_role = string::utf8(b"DeleteAllRecordsAdmin"); + let delete_all_perms = permission::from_vec(vector[permission::delete_all_records()]); + + trail + .access_mut() + .create_role( + &admin_cap, + delete_all_role, + delete_all_perms, + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + let delete_all_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"DeleteAllRecordsAdmin"), + &clock, + ts::ctx(&mut scenario), + ); + + clock.set_for_testing(initial_time_for_testing() + 1000); + let deleted = trail.delete_records_batch( + &delete_all_cap, + 10, + &clock, + ts::ctx(&mut scenario), + ); + assert!(deleted == 1, 0); + assert!(trail.record_count() == 0, 1); + + delete_all_cap.destroy_for_testing(); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + }; + + ts::end(scenario); +} + #[test] #[expected_failure(abort_code = audit_trail::main::ETrailNotEmpty)] fun test_delete_audit_trail_fails_while_not_empty() { diff --git a/audit-trail-move/tests/record_tests.move b/audit-trail-move/tests/record_tests.move index aaf924e6..7d844f5e 100644 --- a/audit-trail-move/tests/record_tests.move +++ b/audit-trail-move/tests/record_tests.move @@ -1317,154 +1317,8 @@ fun test_delete_record_after_time_lock_expires() { ts::end(scenario); } -#[test] -fun test_count_based_locking_old_record_can_delete() { - let admin = @0xAD; - let mut scenario = ts::begin(admin); - - { - let locking_config = locking::new( - locking::window_count_based(2), - timelock::none(), - timelock::none(), - ); - let (admin_cap, _) = setup_test_audit_trail( - &mut scenario, - locking_config, - std::option::none(), - ); - transfer::public_transfer(admin_cap, admin); - }; - - ts::next_tx(&mut scenario, admin); - { - let (admin_cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); - - trail - .access_mut() - .create_role( - &admin_cap, - string::utf8(b"RecordAdmin"), - permission::record_admin_permissions(), - std::option::none(), - &clock, - ts::ctx(&mut scenario), - ); - - let record_cap = test_utils::new_capability_without_restrictions( - trail.access_mut(), - &admin_cap, - &string::utf8(b"RecordAdmin"), - &clock, - ts::ctx(&mut scenario), - ); - - clock.set_for_testing(initial_time_for_testing() + 1000); - - let mut i = 0u64; - while (i < 5) { - trail.add_record( - &record_cap, - record::new_text(string::utf8(b"Record")), - std::option::none(), - std::option::none(), - &clock, - ts::ctx(&mut scenario), - ); - i = i + 1; - }; - - transfer::public_transfer(record_cap, admin); - admin_cap.destroy_for_testing(); - cleanup_trail_and_clock(trail, clock); - }; - - ts::next_tx(&mut scenario, admin); - { - let mut trail = ts::take_shared>(&scenario); - let record_cap = ts::take_from_sender(&scenario); - - let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); - clock.set_for_testing(initial_time_for_testing() + 7200 * 1000); - - assert!(!trail.is_record_locked(0, &clock), 0); - assert!(trail.has_record(0), 1); - - trail.delete_record(&record_cap, 0, &clock, ts::ctx(&mut scenario)); - - assert!(!trail.has_record(0), 2); - - clock::destroy_for_testing(clock); - record_cap.destroy_for_testing(); - ts::return_shared(trail); - }; - - ts::end(scenario); -} - // ===== Delete Records Batch Tests ===== -#[test] -fun test_delete_records_batch_bypasses_record_lock() { - let admin = @0xAD; - let mut scenario = ts::begin(admin); - - { - let locking_config = locking::new( - locking::window_time_based(3600), - timelock::none(), - timelock::none(), - ); - let (admin_cap, _) = setup_test_audit_trail( - &mut scenario, - locking_config, - std::option::some(record::new_text(string::utf8(b"Locked"))), - ); - transfer::public_transfer(admin_cap, admin); - }; - - ts::next_tx(&mut scenario, admin); - { - let (admin_cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); - let delete_all_role = string::utf8(b"DeleteAllRecordsAdmin"); - let delete_all_perms = permission::from_vec(vector[permission::delete_all_records()]); - - trail - .access_mut() - .create_role( - &admin_cap, - delete_all_role, - delete_all_perms, - std::option::none(), - &clock, - ts::ctx(&mut scenario), - ); - - let delete_all_cap = test_utils::new_capability_without_restrictions( - trail.access_mut(), - &admin_cap, - &string::utf8(b"DeleteAllRecordsAdmin"), - &clock, - ts::ctx(&mut scenario), - ); - - clock.set_for_testing(initial_time_for_testing() + 1000); - let deleted = trail.delete_records_batch( - &delete_all_cap, - 10, - &clock, - ts::ctx(&mut scenario), - ); - assert!(deleted == 1, 0); - assert!(trail.record_count() == 0, 1); - - delete_all_cap.destroy_for_testing(); - cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); - }; - - ts::end(scenario); -} - #[test] #[expected_failure(abort_code = audit_trail::role_map::ECapabilityPermissionDenied)] fun test_delete_records_batch_requires_delete_all_records_permission() { From 58960ddfe2dcd82e822fb2d423f69b2a861de512 Mon Sep 17 00:00:00 2001 From: Yasir Date: Thu, 2 Apr 2026 17:55:11 +0300 Subject: [PATCH 136/189] test: extend audit trail coverage --- README.md | 20 ++++++++++---------- audit-trail-rs/src/core/types/role_map.rs | 4 ++-- audit-trail-rs/tests/e2e/records.rs | 7 +------ audit-trail-rs/tests/e2e/trail.rs | 7 +------ 4 files changed, 14 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 6f64c2b4..b4593d6f 100644 --- a/README.md +++ b/README.md @@ -69,19 +69,19 @@ Use **IOTA Audit Trail** when you need shared audit records with permissions, ca ## Toolkits -| Toolkit | Best for | Move Package | Rust SDK | Wasm SDK | -| ------- | -------- | ------------ | -------- | -------- | -| Notarization | Proof objects for documents, hashes, and updatable notarized state | [`notarization-move`](./notarization-move) | [`notarization-rs`](./notarization-rs) | [`notarization_wasm`](./bindings/wasm/notarization_wasm) | -| Audit Trail | Shared sequential records with roles, capabilities, tagging, and locking | [`audit-trail-move`](./audit-trail-move) | [`audit-trail-rs`](./audit-trail-rs) | [`audit_trail_wasm`](./bindings/wasm/audit_trail_wasm) | +| Toolkit | Best for | Move Package | Rust SDK | Wasm SDK | +| ------------ | ------------------------------------------------------------------------ | ------------------------------------------ | -------------------------------------- | -------------------------------------------------------- | +| Notarization | Proof objects for documents, hashes, and updatable notarized state | [`notarization-move`](./notarization-move) | [`notarization-rs`](./notarization-rs) | [`notarization_wasm`](./bindings/wasm/notarization_wasm) | +| Audit Trail | Shared sequential records with roles, capabilities, tagging, and locking | [`audit-trail-move`](./audit-trail-move) | [`audit-trail-rs`](./audit-trail-rs) | [`audit_trail_wasm`](./bindings/wasm/audit_trail_wasm) | ### Which one should I use? -| Need | Best fit | -| ---- | -------- | -| Immutable or updatable proof object for arbitrary data | Notarization | -| Simple proof-of-existence or latest-state notarization flow | Notarization | -| Shared sequential records with roles, capabilities, and record tag policy | Audit Trail | -| Team or system audit log with governance and operational controls | Audit Trail | +| Need | Best fit | +| ------------------------------------------------------------------------- | ------------ | +| Immutable or updatable proof object for arbitrary data | Notarization | +| Simple proof-of-existence or latest-state notarization flow | Notarization | +| Shared sequential records with roles, capabilities, and record tag policy | Audit Trail | +| Team or system audit log with governance and operational controls | Audit Trail | ## Documentation And Resources diff --git a/audit-trail-rs/src/core/types/role_map.rs b/audit-trail-rs/src/core/types/role_map.rs index ad33a07b..f7f98211 100644 --- a/audit-trail-rs/src/core/types/role_map.rs +++ b/audit-trail-rs/src/core/types/role_map.rs @@ -165,11 +165,11 @@ impl MoveType for Capability { #[cfg(test)] mod tests { + use iota_interaction::types::base_types::{IotaAddress, dbg_object_id}; + use iota_interaction::types::id::UID; use serde_json::json; use super::Capability; - use iota_interaction::types::base_types::{IotaAddress, dbg_object_id}; - use iota_interaction::types::id::UID; #[test] fn capability_deserializes_string_encoded_time_constraints() { diff --git a/audit-trail-rs/tests/e2e/records.rs b/audit-trail-rs/tests/e2e/records.rs index 9fafc7be..8555b9d4 100644 --- a/audit-trail-rs/tests/e2e/records.rs +++ b/audit-trail-rs/tests/e2e/records.rs @@ -748,12 +748,7 @@ async fn delete_records_batch_requires_delete_all_records_permission() -> anyhow let records = operator.trail(trail_id).records(); admin - .create_role( - trail_id, - "TrailDeleteOnly", - [Permission::DeleteAuditTrail], - None, - ) + .create_role(trail_id, "TrailDeleteOnly", [Permission::DeleteAuditTrail], None) .await?; admin .issue_cap( diff --git a/audit-trail-rs/tests/e2e/trail.rs b/audit-trail-rs/tests/e2e/trail.rs index 4fe0c98e..4ade501b 100644 --- a/audit-trail-rs/tests/e2e/trail.rs +++ b/audit-trail-rs/tests/e2e/trail.rs @@ -389,12 +389,7 @@ async fn revoked_capability_cannot_update_metadata() -> anyhow::Result<()> { let trail_id = admin.create_test_trail(Data::text("trail-update-meta-revoked")).await?; admin - .create_role( - trail_id, - "MetadataAdmin", - vec![Permission::UpdateMetadata], - None, - ) + .create_role(trail_id, "MetadataAdmin", vec![Permission::UpdateMetadata], None) .await?; let issued = admin .issue_cap( From 86d28e6958c48d250da0a436bba828a8b335ea49 Mon Sep 17 00:00:00 2001 From: Yasir Date: Thu, 2 Apr 2026 17:57:22 +0300 Subject: [PATCH 137/189] style: format record tests --- audit-trail-move/tests/record_tests.move | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/audit-trail-move/tests/record_tests.move b/audit-trail-move/tests/record_tests.move index 7d844f5e..e3be34f2 100644 --- a/audit-trail-move/tests/record_tests.move +++ b/audit-trail-move/tests/record_tests.move @@ -272,9 +272,10 @@ fun test_delete_records_batch_with_matching_role_tags() { .create_role( &admin_cap, string::utf8(b"TaggedDeleteAll"), - permission::from_vec( - vector[permission::add_record(), permission::delete_all_records()], - ), + permission::from_vec(vector[ + permission::add_record(), + permission::delete_all_records(), + ]), std::option::some(record_tags::new_role_tags(vector[string::utf8(b"finance")])), &clock, ts::ctx(&mut scenario), From 59c47bb42237eb01bf89c2cc131b58ef430bd262 Mon Sep 17 00:00:00 2001 From: Yasir Date: Thu, 2 Apr 2026 20:41:10 +0300 Subject: [PATCH 138/189] test: fix tagged batch delete expectation --- audit-trail-rs/tests/e2e/records.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/audit-trail-rs/tests/e2e/records.rs b/audit-trail-rs/tests/e2e/records.rs index 32559f75..973487ed 100644 --- a/audit-trail-rs/tests/e2e/records.rs +++ b/audit-trail-rs/tests/e2e/records.rs @@ -742,8 +742,8 @@ async fn delete_records_batch_with_matching_role_tag_access_succeeds() -> anyhow .build_and_execute(&client) .await? .output; - assert_eq!(deleted, 1); - assert_eq!(records.record_count().await?, 1); + assert_eq!(deleted, 2); + assert_eq!(records.record_count().await?, 0); assert!(records.get(1).await.is_err()); Ok(()) From 4b9f0764f1653e2cb396cdce14d719568b1c8154 Mon Sep 17 00:00:00 2001 From: Yasir Date: Tue, 7 Apr 2026 11:37:39 +0300 Subject: [PATCH 139/189] docs: tighten audit trail core docs --- audit-trail-rs/src/client/full_client.rs | 89 ++++++++++++++++++- audit-trail-rs/src/core/access/mod.rs | 55 ++++++++++-- audit-trail-rs/src/core/access/operations.rs | 44 +++++++++ .../src/core/access/transactions.rs | 31 +++++++ audit-trail-rs/src/core/builder.rs | 20 ++++- .../src/core/create/transactions.rs | 5 +- audit-trail-rs/src/core/locking/mod.rs | 6 ++ audit-trail-rs/src/core/locking/operations.rs | 8 ++ .../src/core/locking/transactions.rs | 10 +++ audit-trail-rs/src/core/mod.rs | 2 +- audit-trail-rs/src/core/records/mod.rs | 17 +++- audit-trail-rs/src/core/records/operations.rs | 15 ++++ .../src/core/records/transactions.rs | 14 +++ audit-trail-rs/src/core/tags/mod.rs | 6 ++ audit-trail-rs/src/core/tags/operations.rs | 5 ++ audit-trail-rs/src/core/tags/transactions.rs | 6 ++ audit-trail-rs/src/core/trail.rs | 31 +++---- audit-trail-rs/src/core/trail/operations.rs | 6 ++ audit-trail-rs/src/core/trail/transactions.rs | 12 ++- audit-trail-rs/src/core/types/mod.rs | 5 +- audit-trail-rs/src/core/types/role_map.rs | 22 +++-- 21 files changed, 365 insertions(+), 44 deletions(-) diff --git a/audit-trail-rs/src/client/full_client.rs b/audit-trail-rs/src/client/full_client.rs index 49b94e97..366733ab 100644 --- a/audit-trail-rs/src/client/full_client.rs +++ b/audit-trail-rs/src/client/full_client.rs @@ -1,10 +1,81 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -//! Signing client support for audit-trail interactions. +//! # Audit Trail Client //! -//! [`AuditTrailClient`] combines an [`AuditTrailClientReadOnly`] with a signer so the crate can -//! build typed write transactions against the connected network. +//! The full client extends [`AuditTrailClientReadOnly`] with signing support and write +//! transaction builders. +//! +//! ## Transaction Flow +//! +//! Write APIs return a [`TransactionBuilder`](product_common::transaction::transaction_builder::TransactionBuilder) +//! that you can configure before signing and submitting: +//! +//! ```rust,no_run +//! # use audit_trail::AuditTrailClient; +//! # use audit_trail::core::types::Data; +//! # async fn example( +//! # client: &AuditTrailClient< +//! # impl secret_storage::Signer + iota_interaction::OptionalSync, +//! # >, +//! # ) -> Result<(), Box> { +//! let created = client +//! .create_trail() +//! .with_initial_record_parts(Data::text("Initial record"), None, None) +//! .finish() +//! .with_gas_budget(1_000_000) +//! .build_and_execute(client) +//! .await?; +//! +//! let trail_id = created.output.trail_id; +//! +//! client +//! .trail(trail_id) +//! .records() +//! .add(Data::text("Follow-up record"), None, None) +//! .build_and_execute(client) +//! .await?; +//! # Ok(()) +//! # } +//! ``` +//! +//! ## Example Workflow +//! +//! ```rust,no_run +//! # use audit_trail::AuditTrailClient; +//! # use audit_trail::core::types::{Data, PermissionSet, RoleTags}; +//! # async fn example( +//! # client: &AuditTrailClient< +//! # impl secret_storage::Signer + iota_interaction::OptionalSync, +//! # >, +//! # ) -> Result<(), Box> { +//! let created = client +//! .create_trail() +//! .with_initial_record_parts(Data::text("Initial record"), None, None) +//! .with_record_tags(["finance"]) +//! .finish() +//! .build_and_execute(client) +//! .await?; +//! +//! let trail_id = created.output.trail_id; +//! +//! client +//! .trail(trail_id) +//! .access() +//! .for_role("TaggedWriter") +//! .create(PermissionSet::record_admin_permissions(), Some(RoleTags::new(["finance"]))) +//! .build_and_execute(client) +//! .await?; +//! +//! client +//! .trail(trail_id) +//! .records() +//! .add(Data::text("Budget approved"), None, Some("finance".to_string())) +//! .build_and_execute(client) +//! .await?; +//! # Ok(()) +//! # } +//! ``` use std::ops::Deref; @@ -55,11 +126,21 @@ pub enum FromIotaClientErrorKind { NetworkResolution(#[source] Box), } -/// A full client that wraps the read-only client and hosts write operations. +/// A client for creating and managing audit trails on the IOTA blockchain. +/// +/// This client combines read-only capabilities with transaction signing, +/// enabling full interaction with audit trails. +/// +/// ## Type Parameter +/// +/// - `S`: The signer type that implements [`Signer`] #[derive(Clone)] pub struct AuditTrailClient { + /// The underlying read-only client used for executing read-only operations. pub(super) read_client: AuditTrailClientReadOnly, + /// The public key associated with the signer, if any. pub(super) public_key: Option, + /// The signer used for signing transactions, or `NoSigner` if the client is read-only. pub(super) signer: S, } diff --git a/audit-trail-rs/src/core/access/mod.rs b/audit-trail-rs/src/core/access/mod.rs index 23feebda..31b142e4 100644 --- a/audit-trail-rs/src/core/access/mod.rs +++ b/audit-trail-rs/src/core/access/mod.rs @@ -2,6 +2,13 @@ // SPDX-License-Identifier: Apache-2.0 //! Role and capability management APIs for audit trails. +//! +//! This module is the Rust-facing wrapper around the access-control state integrated into each audit trail. +//! Roles grant [`PermissionSet`] values, while capability objects bind one role to one trail and may add +//! optional address or time restrictions. +//! +//! Additional record-tag constraints are represented as [`RoleTags`]. They narrow which tagged records a role +//! may operate on, but they do not replace the underlying permission checks enforced by the Move package. use iota_interaction::types::base_types::ObjectID; use iota_interaction::{IotaKeySignature, OptionalSync}; @@ -21,6 +28,9 @@ pub use transactions::{ }; /// Access-control API scoped to a specific trail. +/// +/// This handle exposes role-management and capability-management operations for one trail. All authorization is +/// still enforced against the capability supplied during transaction construction. #[derive(Debug, Clone)] pub struct TrailAccess<'a, C> { pub(crate) client: &'a C, @@ -33,14 +43,17 @@ impl<'a, C> TrailAccess<'a, C> { } /// Returns a role-scoped handle for the given role name. + /// + /// The returned handle only identifies the role. Existence and authorization are checked when the + /// resulting transaction is built and executed. pub fn for_role(&self, name: impl Into) -> RoleHandle<'a, C> { RoleHandle::new(self.client, self.trail_id, name.into()) } /// Revokes an issued capability. /// - /// Pass the capability's `valid_until` value when it is known so the denylist entry matches the on-chain cleanup - /// model. + /// Revocation adds the capability ID to the trail's denylist. Pass the capability's `valid_until` value + /// when it is known so later cleanup keeps the same expiry semantics. pub fn revoke_capability( &self, capability_id: ObjectID, @@ -60,6 +73,9 @@ impl<'a, C> TrailAccess<'a, C> { } /// Destroys a capability object. + /// + /// This consumes the owned capability object itself. It uses the generic capability-destruction path and + /// therefore must not be used for initial-admin capabilities. pub fn destroy_capability(&self, capability_id: ObjectID) -> TransactionBuilder where C: AuditTrailFull + CoreClient, @@ -69,7 +85,10 @@ impl<'a, C> TrailAccess<'a, C> { TransactionBuilder::new(DestroyCapability::new(self.trail_id, owner, capability_id)) } - /// Destroys an initial admin capability (self-service, no auth cap required). + /// Destroys an initial-admin capability without presenting another authorization capability. + /// + /// Initial-admin capability IDs are tracked separately, so they cannot be removed through the generic + /// destroy path. pub fn destroy_initial_admin_capability( &self, capability_id: ObjectID, @@ -81,10 +100,10 @@ impl<'a, C> TrailAccess<'a, C> { TransactionBuilder::new(DestroyInitialAdminCapability::new(self.trail_id, capability_id)) } - /// Revokes an initial admin capability by ID. + /// Revokes an initial-admin capability by ID. /// - /// Pass the capability's `valid_until` value when it is known so the denylist entry matches the on-chain cleanup - /// model. + /// Like [`TrailAccess::revoke_capability`], this writes to the denylist. The dedicated entry point exists + /// because initial-admin capability IDs are protected separately. pub fn revoke_initial_admin_capability( &self, capability_id: ObjectID, @@ -104,6 +123,9 @@ impl<'a, C> TrailAccess<'a, C> { } /// Removes expired entries from the revoked-capability denylist. + /// + /// Only entries whose stored expiry has passed are removed. Revocations without an expiry remain until + /// they are explicitly destroyed or the trail is deleted. pub fn cleanup_revoked_capabilities(&self) -> TransactionBuilder where C: AuditTrailFull + CoreClient, @@ -115,6 +137,9 @@ impl<'a, C> TrailAccess<'a, C> { } /// Role-scoped access-control API. +/// +/// A `RoleHandle` identifies one role name inside the trail's access-control state and builds transactions that +/// act on that role. #[derive(Debug, Clone)] pub struct RoleHandle<'a, C> { pub(crate) client: &'a C, @@ -132,7 +157,12 @@ impl<'a, C> RoleHandle<'a, C> { &self.name } - /// Creates this role with the provided permissions and optional role-tag access rules. + /// Creates this role with the provided permissions and optional role-tag + /// access rules. + /// + /// Any supplied [`RoleTags`] must already exist in the trail-owned tag + /// registry. The tag list is stored as + /// role data on the Move side and is later used for tag-aware record authorization. pub fn create(&self, permissions: PermissionSet, role_tags: Option) -> TransactionBuilder where C: AuditTrailFull + CoreClient, @@ -149,6 +179,12 @@ impl<'a, C> RoleHandle<'a, C> { } /// Issues a capability for this role using optional restrictions. + /// + /// The resulting capability always targets this trail and grants exactly + /// this role. `issued_to`, + /// `valid_from_ms`, and `valid_until_ms` only configure restrictions on + /// the issued object; enforcement + /// happens on-chain when the capability is later used. pub fn issue_capability(&self, options: CapabilityIssueOptions) -> TransactionBuilder where C: AuditTrailFull + CoreClient, @@ -159,6 +195,9 @@ impl<'a, C> RoleHandle<'a, C> { } /// Updates permissions and role-tag access rules for this role. + /// + /// As with [`RoleHandle::create`], any supplied [`RoleTags`] must already + /// exist in the trail tag registry. pub fn update_permissions( &self, permissions: PermissionSet, @@ -179,6 +218,8 @@ impl<'a, C> RoleHandle<'a, C> { } /// Deletes this role. + /// + /// The reserved initial-admin role cannot be deleted. pub fn delete(&self) -> TransactionBuilder where C: AuditTrailFull + CoreClient, diff --git a/audit-trail-rs/src/core/access/operations.rs b/audit-trail-rs/src/core/access/operations.rs index ff792349..3ac51692 100644 --- a/audit-trail-rs/src/core/access/operations.rs +++ b/audit-trail-rs/src/core/access/operations.rs @@ -2,6 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 //! Internal access-control helpers that build role and capability transactions. +//! +//! These helpers encode Rust-side access inputs into the exact Move call shapes expected by the audit-trail +//! package and apply the lightweight preflight checks that are cheaper to surface before submission. use iota_interaction::types::base_types::{IotaAddress, ObjectID}; use iota_interaction::types::transaction::{ObjectArg, ProgrammableTransaction}; @@ -13,9 +16,18 @@ use crate::core::types::{CapabilityIssueOptions, Permission, PermissionSet, Role use crate::error::Error; /// Internal namespace for role and capability transaction construction. +/// +/// Each helper selects the required authorization permission, prepares +/// Move-compatible arguments, and then +/// delegates to the shared trail transaction builders in [`crate::core::internal::tx`]. pub(super) struct AccessOps; impl AccessOps { + /// Builds the `create_role` call. + /// + /// `role_tags`, when present, are validated against the trail tag registry + /// before PTB construction so the + /// Rust side fails early with `Error::InvalidArgument` instead of relying on a later Move abort. pub(super) async fn create_role( client: &C, trail_id: ObjectID, @@ -63,6 +75,10 @@ impl AccessOps { .await } + /// Builds the `update_role_permissions` call. + /// + /// The same tag-registry precondition as [`AccessOps::create_role`] applies because role-tag data is stored + /// on-chain as part of the role definition. pub(super) async fn update_role( client: &C, trail_id: ObjectID, @@ -111,6 +127,10 @@ impl AccessOps { .await } + /// Builds the `delete_role` call. + /// + /// The PTB only carries the role name and clock reference. Protection of the initial-admin role remains an + /// access-control invariant enforced by the Move package. pub(super) async fn delete_role( client: &C, trail_id: ObjectID, @@ -136,6 +156,10 @@ impl AccessOps { .await } + /// Builds the `new_capability` call for a role. + /// + /// Optional restrictions are serialized exactly as provided. Validation of `issued_to`, `valid_from`, and + /// `valid_until` semantics remains on-chain. pub(super) async fn issue_capability( client: &C, trail_id: ObjectID, @@ -165,6 +189,10 @@ impl AccessOps { .await } + /// Builds the generic `revoke_capability` call. + /// + /// `capability_valid_until` is forwarded to the Move layer so the denylist can later be cleaned up without + /// losing the capability's original expiry boundary. pub(super) async fn revoke_capability( client: &C, trail_id: ObjectID, @@ -192,6 +220,10 @@ impl AccessOps { .await } + /// Builds the generic `destroy_capability` call. + /// + /// This resolves the capability object reference up front because the Move entry point consumes the owned + /// capability object rather than only its ID. pub(super) async fn destroy_capability( client: &C, trail_id: ObjectID, @@ -221,6 +253,10 @@ impl AccessOps { .await } + /// Builds the dedicated `destroy_initial_admin_capability` call. + /// + /// Initial-admin capability IDs are tracked separately, so they cannot be destroyed through the generic + /// capability path. pub(super) async fn destroy_initial_admin_capability( client: &C, trail_id: ObjectID, @@ -240,6 +276,10 @@ impl AccessOps { .await } + /// Builds the dedicated `revoke_initial_admin_capability` call. + /// + /// This keeps the same denylist-expiry behavior as [`AccessOps::revoke_capability`] while using the + /// separate Move entry point reserved for tracked initial-admin IDs. pub(super) async fn revoke_initial_admin_capability( client: &C, trail_id: ObjectID, @@ -267,6 +307,10 @@ impl AccessOps { .await } + /// Builds the `cleanup_revoked_capabilities` call. + /// + /// Cleanup only prunes denylist entries whose stored expiry has elapsed. It does not change capability + /// objects and does not revoke any additional IDs. pub(super) async fn cleanup_revoked_capabilities( client: &C, trail_id: ObjectID, diff --git a/audit-trail-rs/src/core/access/transactions.rs b/audit-trail-rs/src/core/access/transactions.rs index 7534614a..6b5cc043 100644 --- a/audit-trail-rs/src/core/access/transactions.rs +++ b/audit-trail-rs/src/core/access/transactions.rs @@ -1,6 +1,11 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +//! Transaction payloads for audit-trail role and capability administration. +//! +//! These types cache the generated programmable transaction, delegate PTB construction to +//! [`super::operations::AccessOps`], and decode the matching Move events into typed Rust outputs. + use async_trait::async_trait; use iota_interaction::OptionalSync; use iota_interaction::rpc_types::{IotaTransactionBlockEffects, IotaTransactionBlockEvents}; @@ -20,6 +25,9 @@ use crate::error::Error; // ===== CreateRole ===== /// Transaction that creates a role on a trail. +/// +/// This maps to the audit-trail `create_role` Move entry point and therefore requires an authorization +/// capability with `AddRoles`. #[derive(Debug, Clone)] pub struct CreateRole { trail_id: ObjectID, @@ -32,6 +40,8 @@ pub struct CreateRole { impl CreateRole { /// Creates a `CreateRole` transaction builder payload. + /// + /// `role_tags`, when present, are serialized as Move `record_tags::RoleTags` role data. pub fn new( trail_id: ObjectID, owner: IotaAddress, @@ -105,6 +115,9 @@ impl Transaction for CreateRole { } /// Transaction that updates an existing role. +/// +/// This updates both the permission set and the optional role-tag data stored for the role. The entry point +/// requires `UpdateRoles`. #[derive(Debug, Clone)] pub struct UpdateRole { trail_id: ObjectID, @@ -190,6 +203,8 @@ impl Transaction for UpdateRole { } /// Transaction that deletes a role. +/// +/// The reserved initial-admin role cannot be deleted even if the caller holds `DeleteRoles`. #[derive(Debug, Clone)] pub struct DeleteRole { trail_id: ObjectID, @@ -257,6 +272,9 @@ impl Transaction for DeleteRole { } /// Transaction that issues a capability for a role. +/// +/// This mints a new capability object for `role` against `trail_id`. Optional issuance restrictions are +/// copied into the capability object and later enforced on-chain. #[derive(Debug, Clone)] pub struct IssueCapability { trail_id: ObjectID, @@ -333,6 +351,9 @@ impl Transaction for IssueCapability { } /// Transaction that revokes a capability. +/// +/// Revocation writes the capability ID into the trail's revoked-capability denylist. Supplying +/// `capability_valid_until` preserves the same expiry boundary later used by denylist cleanup. #[derive(Debug, Clone)] pub struct RevokeCapability { trail_id: ObjectID, @@ -414,6 +435,9 @@ impl Transaction for RevokeCapability { } /// Transaction that destroys a capability object. +/// +/// This path is for ordinary capabilities. Initial-admin capabilities must use +/// [`DestroyInitialAdminCapability`] instead. #[derive(Debug, Clone)] pub struct DestroyCapability { trail_id: ObjectID, @@ -483,6 +507,8 @@ impl Transaction for DestroyCapability { // ===== DestroyInitialAdminCapability ===== /// Transaction that destroys an initial-admin capability without an auth capability. +/// +/// Initial-admin capability IDs are tracked separately and cannot be removed through the generic destroy path. #[derive(Debug, Clone)] pub struct DestroyInitialAdminCapability { trail_id: ObjectID, @@ -550,6 +576,8 @@ impl Transaction for DestroyInitialAdminCapability { // ===== RevokeInitialAdminCapability ===== /// Transaction that revokes an initial-admin capability. +/// +/// This is the dedicated revoke path for capability IDs recognized as active initial-admin capabilities. #[derive(Debug, Clone)] pub struct RevokeInitialAdminCapability { trail_id: ObjectID, @@ -631,6 +659,9 @@ impl Transaction for RevokeInitialAdminCapability { } /// Transaction that cleans up expired revoked-capability entries. +/// +/// This does not revoke additional capabilities. It only prunes denylist entries whose stored expiry has +/// already elapsed. #[derive(Debug, Clone)] pub struct CleanupRevokedCapabilities { trail_id: ObjectID, diff --git a/audit-trail-rs/src/core/builder.rs b/audit-trail-rs/src/core/builder.rs index 627fe4c0..3194b0fe 100644 --- a/audit-trail-rs/src/core/builder.rs +++ b/audit-trail-rs/src/core/builder.rs @@ -1,7 +1,7 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -//! Audit trail builder for creation transactions. +//! Builder for trail-creation transactions. use std::collections::HashSet; @@ -12,6 +12,10 @@ use super::types::{Data, ImmutableMetadata, InitialRecord, LockingConfig}; use crate::core::create::CreateTrail; /// Builder for creating an audit trail. +/// +/// The builder collects the full create-time configuration before it is normalized into the Move `create` +/// call. Any tag list configured here becomes the trail-owned registry that later role-tag and record-tag +/// checks refer to. #[derive(Debug, Clone, Default)] pub struct AuditTrailBuilder { /// Initial admin address that should receive the initial admin capability. @@ -30,6 +34,8 @@ pub struct AuditTrailBuilder { impl AuditTrailBuilder { /// Sets the full initial record input used during trail creation. + /// + /// When present, the initial record is created as sequence number `0`. pub fn with_initial_record(mut self, initial_record: InitialRecord) -> Self { self.initial_record = Some(initial_record); self @@ -47,12 +53,16 @@ impl AuditTrailBuilder { } /// Sets the locking configuration for the trail. + /// + /// This replaces the entire create-time locking configuration. pub fn with_locking_config(mut self, config: LockingConfig) -> Self { self.locking_config = config; self } /// Sets immutable metadata for the trail. + /// + /// Immutable metadata is stored once during creation and cannot be updated later. pub fn with_trail_metadata(mut self, metadata: ImmutableMetadata) -> Self { self.trail_metadata = Some(metadata); self @@ -68,12 +78,16 @@ impl AuditTrailBuilder { } /// Sets updatable metadata for the trail. + /// + /// This seeds the mutable metadata field that later `update_metadata` calls can replace or clear. pub fn with_updatable_metadata(mut self, metadata: impl Into) -> Self { self.updatable_metadata = Some(metadata.into()); self } /// Sets the canonical list of tags that may be used on records in this trail. + /// + /// The list is deduplicated into the trail-owned tag registry during creation. pub fn with_record_tags(mut self, tags: I) -> Self where I: IntoIterator, @@ -83,13 +97,13 @@ impl AuditTrailBuilder { self } - /// Sets the admin address that receives the initial admin capability. + /// Sets the admin address that receives the initial-admin capability. pub fn with_admin(mut self, admin: IotaAddress) -> Self { self.admin = Some(admin); self } - /// Finalizes the builder and creates a transaction builder. + /// Finalizes the builder and creates the trail-creation transaction builder. pub fn finish(self) -> TransactionBuilder { TransactionBuilder::new(CreateTrail::new(self)) } diff --git a/audit-trail-rs/src/core/create/transactions.rs b/audit-trail-rs/src/core/create/transactions.rs index c9fa5eba..551e2bd7 100644 --- a/audit-trail-rs/src/core/create/transactions.rs +++ b/audit-trail-rs/src/core/create/transactions.rs @@ -16,7 +16,7 @@ use crate::core::internal::trail as trail_reader; use crate::core::types::{AuditTrailCreated, Event, OnChainAuditTrail}; use crate::error::Error; -/// Output of a create trail transaction. +/// Output of a successful trail-creation transaction. #[derive(Debug, Clone)] pub struct TrailCreated { /// Newly created trail object ID. @@ -42,6 +42,9 @@ impl TrailCreated { } /// A transaction that creates a new audit trail. +/// +/// The builder state is normalized into the exact Move `create` call shape, including tag-registry setup, +/// optional initial-record creation, and initial-admin capability assignment. #[derive(Debug, Clone)] pub struct CreateTrail { builder: AuditTrailBuilder, diff --git a/audit-trail-rs/src/core/locking/mod.rs b/audit-trail-rs/src/core/locking/mod.rs index c90b034a..837b89f7 100644 --- a/audit-trail-rs/src/core/locking/mod.rs +++ b/audit-trail-rs/src/core/locking/mod.rs @@ -21,6 +21,9 @@ pub use transactions::{UpdateDeleteRecordWindow, UpdateDeleteTrailLock, UpdateLo use self::operations::LockingOps; /// Locking API scoped to a specific trail. +/// +/// This handle updates the trail's locking configuration and queries whether an individual record is currently +/// locked against deletion. #[derive(Debug, Clone)] pub struct TrailLocking<'a, C> { pub(crate) client: &'a C, @@ -33,6 +36,9 @@ impl<'a, C> TrailLocking<'a, C> { } /// Replaces the full locking configuration for the trail. + /// + /// This overwrites all three locking dimensions at once: record delete window, trail delete lock, and + /// write lock. pub fn update(&self, config: LockingConfig) -> TransactionBuilder where C: AuditTrailFull + CoreClient, diff --git a/audit-trail-rs/src/core/locking/operations.rs b/audit-trail-rs/src/core/locking/operations.rs index fd9142fe..f57690fb 100644 --- a/audit-trail-rs/src/core/locking/operations.rs +++ b/audit-trail-rs/src/core/locking/operations.rs @@ -2,6 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 //! Internal helpers that build locking-related programmable transactions. +//! +//! These helpers serialize locking values into the Move shapes used by the trail package and select the +//! corresponding locking-update permissions. use iota_interaction::OptionalSync; use iota_interaction::types::base_types::{IotaAddress, ObjectID}; @@ -16,6 +19,7 @@ use crate::error::Error; pub(super) struct LockingOps; impl LockingOps { + /// Builds the `update_locking_config` call. pub(super) async fn update_locking_config( client: &C, trail_id: ObjectID, @@ -45,6 +49,7 @@ impl LockingOps { .await } + /// Builds the `update_delete_record_window` call. pub(super) async fn update_delete_record_window( client: &C, trail_id: ObjectID, @@ -70,6 +75,7 @@ impl LockingOps { .await } + /// Builds the `update_delete_trail_lock` call. pub(super) async fn update_delete_trail_lock( client: &C, trail_id: ObjectID, @@ -98,6 +104,7 @@ impl LockingOps { .await } + /// Builds the `update_write_lock` call. pub(super) async fn update_write_lock( client: &C, trail_id: ObjectID, @@ -126,6 +133,7 @@ impl LockingOps { .await } + /// Builds the read-only `is_record_locked` call. pub(super) async fn is_record_locked( client: &C, trail_id: ObjectID, diff --git a/audit-trail-rs/src/core/locking/transactions.rs b/audit-trail-rs/src/core/locking/transactions.rs index 68014321..07829c15 100644 --- a/audit-trail-rs/src/core/locking/transactions.rs +++ b/audit-trail-rs/src/core/locking/transactions.rs @@ -1,6 +1,8 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +//! Transaction payloads for locking updates. + use async_trait::async_trait; use iota_interaction::OptionalSync; use iota_interaction::rpc_types::IotaTransactionBlockEffects; @@ -15,6 +17,8 @@ use crate::core::types::{LockingConfig, LockingWindow, TimeLock}; use crate::error::Error; /// Transaction that replaces the full locking configuration. +/// +/// This writes the full `LockingConfig` object and therefore updates all locking dimensions in one call. #[derive(Debug, Clone)] pub struct UpdateLockingConfig { trail_id: ObjectID, @@ -64,6 +68,8 @@ impl Transaction for UpdateLockingConfig { } /// Transaction that updates the delete-record window. +/// +/// This updates only the rule that governs when individual records may be deleted. #[derive(Debug, Clone)] pub struct UpdateDeleteRecordWindow { trail_id: ObjectID, @@ -113,6 +119,8 @@ impl Transaction for UpdateDeleteRecordWindow { } /// Transaction that updates the delete-trail lock. +/// +/// This updates only the time lock guarding deletion of the entire trail object. #[derive(Debug, Clone)] pub struct UpdateDeleteTrailLock { trail_id: ObjectID, @@ -162,6 +170,8 @@ impl Transaction for UpdateDeleteTrailLock { } /// Transaction that updates the write lock. +/// +/// This updates only the time lock guarding future record writes. #[derive(Debug, Clone)] pub struct UpdateWriteLock { trail_id: ObjectID, diff --git a/audit-trail-rs/src/core/mod.rs b/audit-trail-rs/src/core/mod.rs index d4412d3a..53f08953 100644 --- a/audit-trail-rs/src/core/mod.rs +++ b/audit-trail-rs/src/core/mod.rs @@ -3,7 +3,7 @@ //! Core handles, builders, transactions, and domain types for audit trails. //! -//! The modules in this namespace make up the main domain-facing API: +//! This namespace contains the main trail-facing Rust API: //! //! - [`crate::core::access`] exposes role and capability management //! - [`crate::core::builder`] configures trail creation diff --git a/audit-trail-rs/src/core/records/mod.rs b/audit-trail-rs/src/core/records/mod.rs index eaefef9c..2bda85fd 100644 --- a/audit-trail-rs/src/core/records/mod.rs +++ b/audit-trail-rs/src/core/records/mod.rs @@ -32,6 +32,8 @@ use self::operations::RecordsOps; const MAX_LIST_PAGE_LIMIT: usize = 1_000; /// Record API scoped to a specific trail. +/// +/// This handle builds record-oriented transactions and loads record data from the trail's linked-table storage. #[derive(Debug, Clone)] pub struct TrailRecords<'a, C, D = Data> { pub(crate) client: &'a C, @@ -63,6 +65,9 @@ impl<'a, C, D> TrailRecords<'a, C, D> { } /// Builds a transaction that appends a record to the trail. + /// + /// Tagged writes must reference a tag already defined on the trail. They also require a capability whose + /// role allows both `AddRecord` and the requested tag. pub fn add(&self, data: D, metadata: Option, tag: Option) -> TransactionBuilder where C: AuditTrailFull + CoreClient, @@ -74,6 +79,8 @@ impl<'a, C, D> TrailRecords<'a, C, D> { } /// Builds a transaction that deletes a single record. + /// + /// Deletion remains subject to record locking rules and tag-based access restrictions enforced on-chain. pub fn delete(&self, sequence_number: u64) -> TransactionBuilder where C: AuditTrailFull + CoreClient, @@ -84,6 +91,8 @@ impl<'a, C, D> TrailRecords<'a, C, D> { } /// Builds a transaction that deletes up to `limit` records in one operation. + /// + /// Batch deletion removes records from the front of the trail and requires `DeleteAllRecords`. pub fn delete_records_batch(&self, limit: u64) -> TransactionBuilder where C: AuditTrailFull + CoreClient, @@ -118,7 +127,7 @@ impl<'a, C, D> TrailRecords<'a, C, D> { self.client.execute_read_only_transaction(tx).await } - /// List all records into a [`HashMap`]. + /// Lists all records into a [`HashMap`]. /// /// This traverses the full on-chain linked table and can be expensive for large trails. /// For paginated access, use [`list_page`](Self::list_page). @@ -131,7 +140,7 @@ impl<'a, C, D> TrailRecords<'a, C, D> { list_linked_table::<_, Record>(self.client, &records_table, None).await } - /// List all records with a hard cap to protect against expensive traversals. + /// Lists all records with a hard cap to protect against expensive traversals. pub async fn list_with_limit(&self, max_entries: usize) -> Result>, Error> where C: AuditTrailReadOnly, @@ -141,7 +150,7 @@ impl<'a, C, D> TrailRecords<'a, C, D> { list_linked_table::<_, Record>(self.client, &records_table, Some(max_entries)).await } - /// List one page of linked-table records starting from `cursor`. + /// Lists one page of linked-table records starting from `cursor`. /// /// Pass `None` for the first page; use `next_cursor` for subsequent pages. pub async fn list_page(&self, cursor: Option, limit: usize) -> Result, Error> @@ -186,6 +195,7 @@ where C: CoreClientReadOnly + OptionalSync, V: DeserializeOwned, { + // Preserve linked-table order while exposing a page as a stable Rust map keyed by sequence number. if limit == 0 { return Ok((BTreeMap::new(), start_key.or(table.head))); } @@ -229,6 +239,7 @@ where C: CoreClientReadOnly + OptionalSync, V: DeserializeOwned, { + // Full traversal is only allowed when the caller explicitly accepts the current linked-table size. let expected = table.size as usize; let cap = max_entries.unwrap_or(expected); diff --git a/audit-trail-rs/src/core/records/operations.rs b/audit-trail-rs/src/core/records/operations.rs index 0a8ded89..e287de67 100644 --- a/audit-trail-rs/src/core/records/operations.rs +++ b/audit-trail-rs/src/core/records/operations.rs @@ -2,6 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 //! Internal record-operation helpers that build trail-scoped programmable transactions. +//! +//! These helpers enforce the Rust-side preflight checks around record tags and then encode the exact Move call +//! arguments expected by the trail package. use iota_interaction::OptionalSync; use iota_interaction::types::base_types::{IotaAddress, ObjectID}; @@ -16,6 +19,10 @@ use crate::error::Error; pub(super) struct RecordsOps; impl RecordsOps { + /// Builds the `add_record` call. + /// + /// Tagged writes are prevalidated against the trail tag registry and require a capability whose role allows + /// both `AddRecord` and the requested tag. pub(super) async fn add_record( client: &C, trail_id: ObjectID, @@ -68,6 +75,9 @@ impl RecordsOps { } } + /// Builds the `delete_record` call. + /// + /// Authorization and locking remain enforced by the Move entry point. pub(super) async fn delete_record( client: &C, trail_id: ObjectID, @@ -92,6 +102,9 @@ impl RecordsOps { .await } + /// Builds the `delete_records_batch` call. + /// + /// Batch deletion requires `DeleteAllRecords` and deletes from the front of the trail. pub(super) async fn delete_records_batch( client: &C, trail_id: ObjectID, @@ -116,6 +129,7 @@ impl RecordsOps { .await } + /// Builds the read-only `get_record` call. pub(super) async fn get_record( client: &C, trail_id: ObjectID, @@ -131,6 +145,7 @@ impl RecordsOps { .await } + /// Builds the read-only `record_count` call. pub(super) async fn record_count(client: &C, trail_id: ObjectID) -> Result where C: CoreClientReadOnly + OptionalSync, diff --git a/audit-trail-rs/src/core/records/transactions.rs b/audit-trail-rs/src/core/records/transactions.rs index af2d4ef9..8090bb4b 100644 --- a/audit-trail-rs/src/core/records/transactions.rs +++ b/audit-trail-rs/src/core/records/transactions.rs @@ -1,6 +1,11 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +//! Transaction payloads for record writes and deletions. +//! +//! These types cache the generated programmable transaction, delegate PTB construction to +//! [`super::operations::RecordsOps`], and decode record events into typed Rust outputs. + use async_trait::async_trait; use iota_interaction::OptionalSync; use iota_interaction::rpc_types::{IotaTransactionBlockEffects, IotaTransactionBlockEvents}; @@ -17,6 +22,9 @@ use crate::error::Error; // ===== AddRecord ===== /// Transaction that appends a record to a trail. +/// +/// Tagged writes require the tag to exist in the trail registry and a capability whose role explicitly allows +/// that tag in addition to `AddRecord`. #[derive(Debug, Clone)] pub struct AddRecord { /// Trail object ID that will receive the record. @@ -109,6 +117,9 @@ impl Transaction for AddRecord { // ===== DeleteRecord ===== /// Transaction that deletes a single record. +/// +/// This uses the single-record delete entry point, which remains subject to record-locking and tag-aware +/// authorization checks. #[derive(Debug, Clone)] pub struct DeleteRecord { /// Trail object ID containing the record. @@ -181,6 +192,9 @@ impl Transaction for DeleteRecord { // ===== DeleteRecordsBatch ===== /// Transaction that deletes multiple records in a batch operation. +/// +/// The Move entry point deletes records from the front of the trail up to `limit` and reports the number of +/// deleted records through the emitted `RecordDeleted` events. #[derive(Debug, Clone)] pub struct DeleteRecordsBatch { /// Trail object ID containing the records. diff --git a/audit-trail-rs/src/core/tags/mod.rs b/audit-trail-rs/src/core/tags/mod.rs index 0bb995f8..3171c276 100644 --- a/audit-trail-rs/src/core/tags/mod.rs +++ b/audit-trail-rs/src/core/tags/mod.rs @@ -17,6 +17,8 @@ mod transactions; pub use transactions::{AddRecordTag, RemoveRecordTag}; /// Tag-registry API scoped to a specific trail. +/// +/// The registry defines the canonical set of tags that records and role-tag restrictions may reference. #[derive(Debug, Clone)] pub struct TrailTags<'a, C> { pub(crate) client: &'a C, @@ -29,6 +31,8 @@ impl<'a, C> TrailTags<'a, C> { } /// Adds a tag to the trail-owned record-tag registry. + /// + /// Added tags become available to future tagged record writes and role-tag restrictions. pub fn add(&self, tag: impl Into) -> TransactionBuilder where C: AuditTrailFull + CoreClient, @@ -39,6 +43,8 @@ impl<'a, C> TrailTags<'a, C> { } /// Removes a tag from the trail-owned record-tag registry. + /// + /// Removal fails on-chain while the tag is still referenced by existing records or role-tag policies. pub fn remove(&self, tag: impl Into) -> TransactionBuilder where C: AuditTrailFull + CoreClient, diff --git a/audit-trail-rs/src/core/tags/operations.rs b/audit-trail-rs/src/core/tags/operations.rs index b86b5499..bcf65c54 100644 --- a/audit-trail-rs/src/core/tags/operations.rs +++ b/audit-trail-rs/src/core/tags/operations.rs @@ -2,6 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 //! Internal helpers that build record-tag registry transactions. +//! +//! These helpers encode updates to the trail-owned tag registry and select the corresponding tag-management +//! permissions. use iota_interaction::OptionalSync; use iota_interaction::types::base_types::{IotaAddress, ObjectID}; @@ -16,6 +19,7 @@ use crate::error::Error; pub(super) struct TagsOps; impl TagsOps { + /// Builds the `add_record_tag` call. pub(super) async fn add_record_tag( client: &C, trail_id: ObjectID, @@ -40,6 +44,7 @@ impl TagsOps { .await } + /// Builds the `remove_record_tag` call. pub(super) async fn remove_record_tag( client: &C, trail_id: ObjectID, diff --git a/audit-trail-rs/src/core/tags/transactions.rs b/audit-trail-rs/src/core/tags/transactions.rs index f10976bf..c3af8597 100644 --- a/audit-trail-rs/src/core/tags/transactions.rs +++ b/audit-trail-rs/src/core/tags/transactions.rs @@ -1,6 +1,8 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +//! Transaction payloads for tag-registry updates. + use async_trait::async_trait; use iota_interaction::OptionalSync; use iota_interaction::rpc_types::IotaTransactionBlockEffects; @@ -14,6 +16,8 @@ use super::operations::TagsOps; use crate::error::Error; /// Transaction that adds a record tag to the trail registry. +/// +/// This extends the canonical tag registry owned by the trail. #[derive(Debug, Clone)] pub struct AddRecordTag { trail_id: ObjectID, @@ -63,6 +67,8 @@ impl Transaction for AddRecordTag { } /// Transaction that removes a record tag from the trail registry. +/// +/// Removal only succeeds when the tag is no longer used by records or role-tag restrictions. #[derive(Debug, Clone)] pub struct RemoveRecordTag { trail_id: ObjectID, diff --git a/audit-trail-rs/src/core/trail.rs b/audit-trail-rs/src/core/trail.rs index a4521949..b8f8211c 100644 --- a/audit-trail-rs/src/core/trail.rs +++ b/audit-trail-rs/src/core/trail.rs @@ -1,7 +1,7 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -//! High-level trail handle types and trail-scoped transactions. +//! High-level trail handles and trail-scoped transactions. use iota_interaction::types::base_types::ObjectID; use iota_interaction::types::transaction::ProgrammableTransaction; @@ -24,7 +24,7 @@ mod transactions; pub use transactions::{DeleteAuditTrail, Migrate, UpdateMetadata}; -/// Marker trait for read-only audit trail clients. +/// Marker trait for read-only audit-trail clients. #[doc(hidden)] #[cfg_attr(not(feature = "send-sync"), async_trait::async_trait(?Send))] #[cfg_attr(feature = "send-sync", async_trait::async_trait)] @@ -34,15 +34,14 @@ pub trait AuditTrailReadOnly: CoreClientReadOnly + OptionalSync { -> Result; } -/// Marker trait for full (read-write) audit trail clients. +/// Marker trait for full audit-trail clients. #[doc(hidden)] pub trait AuditTrailFull: AuditTrailReadOnly {} -/// A typed handle bound to a specific audit trail and client. +/// A typed handle bound to one trail ID and one client. /// -/// `AuditTrailHandle` is the main trail-scoped entry point. It keeps the trail ID together with -/// the client so that record, locking, access-control, tag, and metadata operations can all hang -/// off one typed value. +/// This is the main trail-scoped entry point. It keeps the trail identity together with the client so record, +/// locking, access, tag, migration, and metadata operations all share one typed handle. #[derive(Debug, Clone)] pub struct AuditTrailHandle<'a, C> { pub(crate) client: &'a C, @@ -56,7 +55,7 @@ impl<'a, C> AuditTrailHandle<'a, C> { /// Loads the full on-chain audit trail object. /// - /// Each call fetches a fresh snapshot from chain state. + /// Each call fetches a fresh snapshot from chain state rather than reusing cached client-side data. pub async fn get(&self) -> Result where C: AuditTrailReadOnly, @@ -64,7 +63,9 @@ impl<'a, C> AuditTrailHandle<'a, C> { trail_reader::get_audit_trail(self.trail_id, self.client).await } - /// Updates the trail's updatable metadata. + /// Updates the trail's mutable metadata field. + /// + /// Passing `None` clears the field on-chain. pub fn update_metadata(&self, metadata: Option) -> TransactionBuilder where C: AuditTrailFull + CoreClient, @@ -74,7 +75,7 @@ impl<'a, C> AuditTrailHandle<'a, C> { TransactionBuilder::new(UpdateMetadata::new(self.trail_id, owner, metadata)) } - /// Migrates the trail to the latest package version. + /// Migrates the trail to the latest package version supported by this crate. pub fn migrate(&self) -> TransactionBuilder where C: AuditTrailFull + CoreClient, @@ -84,9 +85,9 @@ impl<'a, C> AuditTrailHandle<'a, C> { TransactionBuilder::new(Migrate::new(self.trail_id, owner)) } - /// Deletes the audit trail object. + /// Deletes the trail object. /// - /// The trail must be empty before deletion. + /// Deletion requires the trail to be empty and to satisfy the trail-delete lock rules. pub fn delete_audit_trail(&self) -> TransactionBuilder where C: AuditTrailFull + CoreClient, @@ -98,14 +99,14 @@ impl<'a, C> AuditTrailHandle<'a, C> { /// Returns the record API scoped to this trail. /// - /// Use this for record reads and record-oriented transaction builders. + /// Use this for record reads, appends, and deletions. pub fn records(&self) -> TrailRecords<'a, C, Data> { TrailRecords::new(self.client, self.trail_id) } /// Returns the locking API scoped to this trail. /// - /// Use this for checking and updating trail-level locking rules. + /// Use this for inspecting lock state and updating locking rules. pub fn locking(&self) -> TrailLocking<'a, C> { TrailLocking::new(self.client, self.trail_id) } @@ -119,7 +120,7 @@ impl<'a, C> AuditTrailHandle<'a, C> { /// Returns the tag-registry API scoped to this trail. /// - /// Use this for managing the set of tags available to records in this trail. + /// Use this for managing the canonical tag registry that record writes and role tags must reference. pub fn tags(&self) -> TrailTags<'a, C> { TrailTags::new(self.client, self.trail_id) } diff --git a/audit-trail-rs/src/core/trail/operations.rs b/audit-trail-rs/src/core/trail/operations.rs index 975c09ce..72026c93 100644 --- a/audit-trail-rs/src/core/trail/operations.rs +++ b/audit-trail-rs/src/core/trail/operations.rs @@ -2,6 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 //! Internal helpers that build trail-level programmable transactions. +//! +//! These helpers select the required trail-level permission and encode the corresponding metadata, migration, +//! and deletion calls. use iota_interaction::OptionalSync; use iota_interaction::types::base_types::{IotaAddress, ObjectID}; @@ -16,6 +19,7 @@ use crate::error::Error; pub(super) struct TrailOps; impl TrailOps { + /// Builds the `migrate` call. pub(super) async fn migrate( client: &C, trail_id: ObjectID, @@ -31,6 +35,7 @@ impl TrailOps { .await } + /// Builds the `update_metadata` call. pub(super) async fn update_metadata( client: &C, trail_id: ObjectID, @@ -55,6 +60,7 @@ impl TrailOps { .await } + /// Builds the `delete_audit_trail` call. pub(super) async fn delete_audit_trail( client: &C, trail_id: ObjectID, diff --git a/audit-trail-rs/src/core/trail/transactions.rs b/audit-trail-rs/src/core/trail/transactions.rs index 34174bdc..57efe686 100644 --- a/audit-trail-rs/src/core/trail/transactions.rs +++ b/audit-trail-rs/src/core/trail/transactions.rs @@ -1,6 +1,8 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +//! Transaction payloads for trail-level metadata, migration, and deletion operations. + use async_trait::async_trait; use iota_interaction::OptionalSync; use iota_interaction::rpc_types::{IotaTransactionBlockEffects, IotaTransactionBlockEvents}; @@ -14,7 +16,10 @@ use super::operations::TrailOps; use crate::core::types::{AuditTrailDeleted, Event}; use crate::error::Error; -/// Transaction that migrates a trail to the latest supported package version. +/// Transaction that migrates a trail to the latest package version supported by this crate. +/// +/// This requires `Migrate` on the trail and succeeds only when the on-chain package version is older than the +/// current supported version. #[derive(Debug, Clone)] pub struct Migrate { trail_id: ObjectID, @@ -62,6 +67,8 @@ impl Transaction for Migrate { } /// Transaction that updates mutable trail metadata. +/// +/// Passing `None` clears the mutable metadata field. #[derive(Debug, Clone)] pub struct UpdateMetadata { trail_id: ObjectID, @@ -111,6 +118,9 @@ impl Transaction for UpdateMetadata { } /// Transaction that deletes an empty trail. +/// +/// Deletion still depends on the trail-delete permission, an empty record set, and the configured trail-delete +/// lock. #[derive(Debug, Clone)] pub struct DeleteAuditTrail { trail_id: ObjectID, diff --git a/audit-trail-rs/src/core/types/mod.rs b/audit-trail-rs/src/core/types/mod.rs index 301427fd..486299f2 100644 --- a/audit-trail-rs/src/core/types/mod.rs +++ b/audit-trail-rs/src/core/types/mod.rs @@ -3,9 +3,8 @@ //! Shared serializable domain types for audit trails. //! -//! These types mirror the on-chain data model closely enough to deserialize ledger state and -//! events, while still providing a Rust-native API for builders, permission management, and -//! higher-level client flows. +//! These types stay close to the on-chain data model so they can deserialize ledger state and events while also +//! serving as the typed inputs and outputs of the Rust client API. /// On-chain trail metadata types. pub mod audit_trail; diff --git a/audit-trail-rs/src/core/types/role_map.rs b/audit-trail-rs/src/core/types/role_map.rs index f7f98211..29e45c41 100644 --- a/audit-trail-rs/src/core/types/role_map.rs +++ b/audit-trail-rs/src/core/types/role_map.rs @@ -19,7 +19,10 @@ use crate::core::internal::move_collections::{deserialize_vec_map, deserialize_v use crate::core::internal::tx; use crate::error::Error; -/// On-chain role and capability configuration for a trail. +/// Role and capability configuration stored on a trail. +/// +/// This mirrors the access-control state maintained by the Move package, including the reserved initial-admin +/// role, the revoked-capability denylist, and the role data used for tag-aware authorization. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RoleMap { /// Trail object ID that this role map protects. @@ -50,7 +53,7 @@ pub struct Role { pub data: Option, } -/// Defines the permissions required to administer roles in this RoleMap. +/// Permissions required to administer roles in the trail's access-control state. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RoleAdminPermissions { /// Permission required to create roles. @@ -61,7 +64,7 @@ pub struct RoleAdminPermissions { pub update: Permission, } -/// Defines the permissions required to administer capabilities in this RoleMap. +/// Permissions required to administer capabilities in the trail's access-control state. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CapabilityAdminPermissions { /// Permission required to issue capabilities. @@ -71,6 +74,9 @@ pub struct CapabilityAdminPermissions { } /// Capability issuance options used by the role-based API. +/// +/// These fields only configure restrictions on the issued capability object. Matching against the current +/// caller and timestamp happens when the capability is later used. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct CapabilityIssueOptions { /// Address that should own the capability, if any. @@ -81,10 +87,9 @@ pub struct CapabilityIssueOptions { pub valid_until_ms: Option, } -/// Allowlisted record tags stored as role data on the Move side. +/// Allowlisted record tags stored as role data. /// -/// The Rust name stays `RecordTags` for API continuity, but it maps to the -/// Move `record_tags::RoleTags` type. +/// The Rust name stays `RecordTags` for API continuity, but it maps to Move `record_tags::RoleTags`. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] pub struct RoleTags { /// Allowlisted record tags for the role. @@ -94,6 +99,8 @@ pub struct RoleTags { impl RoleTags { /// Creates role-tag restrictions from an iterator of tag names. + /// + /// The set is deduplicated, and PTB encoding later sorts the tags for deterministic serialization. pub fn new(tags: I) -> Self where I: IntoIterator, @@ -129,6 +136,9 @@ impl RoleTags { } /// Capability data returned by the Move capability module. +/// +/// A capability grants exactly one role against exactly one trail and may additionally restrict who may use it +/// and during which time window it is valid. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Capability { /// Capability object ID. From 22322dca4d3748234e0a93b49eccda303b075e0e Mon Sep 17 00:00:00 2001 From: Yasir Date: Tue, 7 Apr 2026 11:40:22 +0300 Subject: [PATCH 140/189] refactor: simplify audit trail package overrides --- audit-trail-rs/src/client/read_only.rs | 4 ++-- audit-trail-rs/src/package.rs | 6 +++--- audit-trail-rs/tests/e2e/client.rs | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/audit-trail-rs/src/client/read_only.rs b/audit-trail-rs/src/client/read_only.rs index cd866eed..3b765a4d 100644 --- a/audit-trail-rs/src/client/read_only.rs +++ b/audit-trail-rs/src/client/read_only.rs @@ -33,9 +33,9 @@ use crate::package; #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct PackageOverrides { /// Override for the audit-trail package itself. - pub audit_trail_package_id: Option, + pub audit_trail: Option, /// Override for the `tf_components` package used by time locks and capabilities. - pub tf_components_package_id: Option, + pub tf_component: Option, } /// A read-only client for interacting with audit-trail objects on a specific network. diff --git a/audit-trail-rs/src/package.rs b/audit-trail-rs/src/package.rs index 4818f56b..0d443bb7 100644 --- a/audit-trail-rs/src/package.rs +++ b/audit-trail-rs/src/package.rs @@ -85,7 +85,7 @@ pub(crate) async fn resolve_package_ids( let chain_id = network.as_ref().to_string(); let package_registry = audit_trail_package_registry().await; let audit_trail_package_id = package_overrides - .audit_trail_package_id + .audit_trail .or_else(|| package_registry.package_id(network)) .ok_or_else(|| { Error::InvalidConfig(format!( @@ -105,12 +105,12 @@ pub(crate) async fn resolve_package_ids( drop(package_registry); let env = Env::new_with_alias(chain_id.clone(), resolved_network.as_ref()); - if let Some(audit_trail_package_id) = package_overrides.audit_trail_package_id { + if let Some(audit_trail_package_id) = package_overrides.audit_trail { audit_trail_package_registry_mut() .await .insert_env_history(env.clone(), vec![audit_trail_package_id]); } - if let Some(tf_components_package_id) = package_overrides.tf_components_package_id { + if let Some(tf_components_package_id) = package_overrides.tf_component { tf_components_override_registry_mut() .await .insert_env_history(env, vec![tf_components_package_id]); diff --git a/audit-trail-rs/tests/e2e/client.rs b/audit-trail-rs/tests/e2e/client.rs index 2b88f6d0..0bca63b4 100644 --- a/audit-trail-rs/tests/e2e/client.rs +++ b/audit-trail-rs/tests/e2e/client.rs @@ -172,8 +172,8 @@ impl TestClient { let client = AuditTrailClient::from_iota_client( iota_client.clone(), Some(PackageOverrides { - audit_trail_package_id: Some(package_ids.audit_trail_package_id), - tf_components_package_id: package_ids.tf_components_package_id, + audit_trail: Some(package_ids.audit_trail_package_id), + tf_component: package_ids.tf_components_package_id, }), ) .await?; From 3eca854dcbc0413874de19fe95e03084a75c3212 Mon Sep 17 00:00:00 2001 From: Yasir Date: Tue, 7 Apr 2026 12:49:12 +0300 Subject: [PATCH 141/189] fix: align audit trail wasm package overrides --- bindings/wasm/audit_trail_wasm/src/client.rs | 4 ++-- bindings/wasm/audit_trail_wasm/src/client_read_only.rs | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/bindings/wasm/audit_trail_wasm/src/client.rs b/bindings/wasm/audit_trail_wasm/src/client.rs index fe65afb0..acc65f13 100644 --- a/bindings/wasm/audit_trail_wasm/src/client.rs +++ b/bindings/wasm/audit_trail_wasm/src/client.rs @@ -49,8 +49,8 @@ impl WasmAuditTrailClient { AuditTrailClientReadOnly::new_with_package_overrides( iota_client, PackageOverrides { - audit_trail_package_id: Some(package_id), - tf_components_package_id: None, + audit_trail: Some(package_id), + tf_component: None, }, ) .await diff --git a/bindings/wasm/audit_trail_wasm/src/client_read_only.rs b/bindings/wasm/audit_trail_wasm/src/client_read_only.rs index 042d72e2..77d9db49 100644 --- a/bindings/wasm/audit_trail_wasm/src/client_read_only.rs +++ b/bindings/wasm/audit_trail_wasm/src/client_read_only.rs @@ -43,12 +43,12 @@ impl TryFrom for PackageOverrides { fn try_from(value: WasmPackageOverrides) -> std::result::Result { Ok(Self { - audit_trail_package_id: value + audit_trail: value .audit_trail_package_id .as_ref() .map(parse_wasm_object_id) .transpose()?, - tf_components_package_id: value + tf_component: value .tf_components_package_id .as_ref() .map(parse_wasm_object_id) @@ -107,8 +107,8 @@ impl WasmAuditTrailClientReadOnly { let client = AuditTrailClientReadOnly::new_with_package_overrides( iota_client, PackageOverrides { - audit_trail_package_id: Some(package_id), - tf_components_package_id: None, + audit_trail: Some(package_id), + tf_component: None, }, ) .await From 8dc1b2525955018052bee43c2e2f5f5b5abc0908 Mon Sep 17 00:00:00 2001 From: chrisgitiota Date: Tue, 7 Apr 2026 13:17:28 +0200 Subject: [PATCH 142/189] Some detail fixes --- .../src/core/types/RoleMap-README.md | 51 +++++++++++++------ 1 file changed, 35 insertions(+), 16 deletions(-) diff --git a/audit-trail-rs/src/core/types/RoleMap-README.md b/audit-trail-rs/src/core/types/RoleMap-README.md index 86668204..22eadf44 100644 --- a/audit-trail-rs/src/core/types/RoleMap-README.md +++ b/audit-trail-rs/src/core/types/RoleMap-README.md @@ -1,13 +1,13 @@ -# RoleMap — Role-Based Access Control for Audit Trails +# Role-Based Access Control for Audit Trails -A `RoleMap` is the access control registry embedded in every audit trail. -It defines who may perform which operations by combining two primitives: +Audit trails provide an access control registry (a.k.a. `RoleMap`), defining who may perform which +operations by combining two primitives: - **Roles** — named permission sets stored on the trail. - **Capabilities** — on-chain objects held by users, each linked to one role. Every operation on a trail (adding a record, deleting a role, revoking a -capability, …) requires the caller to present a `Capability`. The `RoleMap` +capability, …) requires the caller to present a `Capability`. The audit trail validates the capability before allowing the operation. --- @@ -16,7 +16,7 @@ validates the capability before allowing the operation. ### Roles -A role is a named set of `Permission` values, for example: +A role is a named and configurable set of `Permission` values, for example: | Role name | Permissions | |:----------------|:--------------------------------------------| @@ -26,8 +26,8 @@ A role is a named set of `Permission` values, for example: | `Auditor` | *(read-only — no write permissions needed)* | Roles are identified by a unique string name within the trail. Multiple -capabilities can be issued for the same role, one per user or service that -should share that access level. +capabilities can be issued for the same role, to allow users or services to share +that access level. Roles may optionally carry a `RoleTags` allowlist (see [Record Tags](#record-tags-and-roletags)). @@ -43,20 +43,22 @@ A `Capability` is an on-chain object owned by a wallet address. It records: | `valid_from` | Optional Unix-ms timestamp before which the cap is not yet active. | | `valid_until` | Optional Unix-ms timestamp after which the cap expires. | -Possessing a capability does **not** automatically grant access. The `RoleMap` +Possessing a capability does **not** automatically grant access. The audit trail validates all fields above on every call before the operation is executed. ### The Admin Role -When a trail is created, the `RoleMap` initialises with exactly one role — +When a trail is created, the access control registry is initialized with exactly one role — the **initial admin role** (named `"Admin"`). A corresponding capability object is minted and transferred to the trail creator (or a custom address supplied via `with_admin`). The Admin role is protected by two invariants: 1. It can **never be deleted**. -2. Its permission set can only be updated to a set that still includes all - configured role- and capability-admin permissions. +2. Although its permission set can be updated, it needs to include a minimum set of + permissions to manage the trail's access control (AddRoles, UpdateRoles, DeleteRoles, + AddCapabilities, RevokeCapabilities). Removing any of these permissions from the Admin + role will fail. Initial admin capabilities are tracked in `initial_admin_cap_ids` and must be managed through dedicated entry-points (`revoke_initial_admin_capability`, @@ -64,7 +66,7 @@ managed through dedicated entry-points (`revoke_initial_admin_capability`, --- -## Lifecycle +## Lifecycle Example ### 1 — Trail is created @@ -109,7 +111,7 @@ Admin Capability + revoke_capability(cap_id, valid_until) ──► RoleMap.revoked_capabilities: { cap_id → valid_until_ms } ``` -The capability object still exists on-chain but is rejected by +Please note: Revoked capability objects still exist on-chain but will be rejected by `assert_capability_valid`. The holder can no longer use it. --- @@ -244,13 +246,13 @@ before it can be used on a record or referenced by a role. ### Why use tags? Tags enable fine-grained access control beyond simple permission checks. For -example, a legal department may only be allowed to read records tagged +example, a legal department may only be allowed to access records tagged `"legal"`, while the finance team works with records tagged `"finance"`. ### How tags interact with roles A role may carry an optional `RoleTags` allowlist. When a capability holder -adds a record with a tag, the `RoleMap` checks that: +adds a record with a tag, the audit trail checks that: 1. The tag is registered in the trail's tag registry. 2. The role associated with the capability includes the requested tag in its @@ -258,6 +260,16 @@ adds a record with a tag, the `RoleMap` checks that: If either check fails the transaction is rejected. +The same checks apply when a record having a tag is updated or deleted. + +Please note: +* Tags only restrict the use of tagged records to roles that explicitly + grant access to those tags in the associated `RoleTags` allowlist. +* Tags do not grant access permission themselves. A role still needs the relevant + permissions (e.g. `AddRecord`) to perform operations on tagged records. +* A role without any `RoleTags` can operate on any record not having tags, as long + as it has the necessary permissions. + ### Example — tagged records ```rust @@ -308,7 +320,7 @@ records tagged `"legal"`. ## Denylist Management -The `RoleMap` uses a **denylist** (not an allowlist) for revocation. This +The audit trail uses a **denylist** (not an allowlist) for revocation. This keeps on-chain storage proportional to the number of *currently revoked* capabilities, not the total number ever issued. @@ -338,3 +350,10 @@ Implications: | `cap_admin_permissions()` | AddCapabilities, RevokeCapabilities | | `tag_admin_permissions()` | AddRecordTags, DeleteRecordTags | | `metadata_admin_permissions()`| UpdateMetadata, DeleteMetadata | + +Please note: +* These constructors are just for convenience and do not enforce any invariants. + For example, you could (not recommended) create a role named `NormalUser` with + `PermissionSet::admin_permissions()` +* You can create custom permission sets by constructing a `PermissionSet` with + an arbitrary combination of permissions. \ No newline at end of file From 2823dc1cbcec0ba418a6036eca57fa4c7e2c68e1 Mon Sep 17 00:00:00 2001 From: chrisgitiota Date: Tue, 7 Apr 2026 13:30:03 +0200 Subject: [PATCH 143/189] Extended `Capability Validation Rules` and `Managing Revoked Capabilities` sections Both sections need to be reviewed though --- .../src/core/types/RoleMap-README.md | 186 +++++++++++++++--- 1 file changed, 162 insertions(+), 24 deletions(-) diff --git a/audit-trail-rs/src/core/types/RoleMap-README.md b/audit-trail-rs/src/core/types/RoleMap-README.md index 22eadf44..cbb5e756 100644 --- a/audit-trail-rs/src/core/types/RoleMap-README.md +++ b/audit-trail-rs/src/core/types/RoleMap-README.md @@ -305,36 +305,174 @@ records tagged `"legal"`. ## Capability Validation Rules -`assert_capability_valid` rejects a capability if any of the following hold: +Every operation on a trail calls `assert_capability_valid` before executing. +The checks run in the order listed below; the transaction aborts on the +**first** failing check. -| Check | Error | -|:------------------------|:------------------------------------| -| `target_key` mismatch | `ECapabilityTargetKeyMismatch` | -| Role does not exist | `ERoleDoesNotExist` | -| Permission not in role | `ECapabilityPermissionDenied` | -| ID in revoked denylist | `ECapabilityHasBeenRevoked` | -| Outside validity window | `ECapabilityTimeConstraintsNotMet` | -| `issued_to` mismatch | `ECapabilityIssuedToMismatch` | +### 1 — `ECapabilityTargetKeyMismatch` ---- +The capability's `target_key` must match the `target_key` of the RoleMap +(which is typically the `ObjectID` of the audit trail). This prevents a +capability issued for one trail from being used on a different trail. + +### 2 — `ERoleDoesNotExist` + +The role name stored in the capability must still exist in the RoleMap. If +an admin deleted the role after the capability was issued, the capability +becomes unusable — even though it was never explicitly revoked. + +### 3 — `ECapabilityPermissionDenied` + +The role's current permission set must contain the permission required by the +operation being performed. For example, calling `add_record` requires the +`AddRecord` permission. If the role was updated after the capability was +issued and the required permission was removed, existing capabilities for +that role will start failing this check. + +### 4 — `ECapabilityHasBeenRevoked` + +The capability's ID must **not** appear in the `revoked_capabilities` +denylist. A capability that has been revoked via `revoke_capability` (or +`revoke_initial_admin_capability`) is permanently rejected, even if it is +still within its validity window. See +[Managing Revoked Capabilities](#managing-revoked-capabilities) for details. + +### 5 — `ECapabilityTimeConstraintsNotMet` -## Denylist Management +This check only runs when the capability has a `valid_from` and/or +`valid_until` field set. The current on-chain clock time must satisfy: -The audit trail uses a **denylist** (not an allowlist) for revocation. This -keeps on-chain storage proportional to the number of *currently revoked* -capabilities, not the total number ever issued. +- `valid_from`: current time **>=** `valid_from` (the capability is not yet + active before this timestamp). +- `valid_until`: current time **<=** `valid_until` (the capability has + expired after this timestamp). -Implications: +If neither field is set, this check is skipped entirely and the capability is +considered valid at any point in time. + +### 6 — `ECapabilityIssuedToMismatch` + +This check only runs when the capability has a non-empty `issued_to` field. +The address of the transaction sender must match the `issued_to` address +stored in the capability. This binds the capability to a specific wallet, +preventing it from being used by anyone else even if the on-chain object is +transferred. + +If `issued_to` is not set, any holder of the capability object may use it. + +### Summary + +| # | Check | Error | Skippable | +|:--|:------------------------|:------------------------------------|:----------| +| 1 | `target_key` mismatch | `ECapabilityTargetKeyMismatch` | No | +| 2 | Role does not exist | `ERoleDoesNotExist` | No | +| 3 | Permission not in role | `ECapabilityPermissionDenied` | No | +| 4 | ID in revoked denylist | `ECapabilityHasBeenRevoked` | No | +| 5 | Outside validity window | `ECapabilityTimeConstraintsNotMet` | Yes — only if `valid_from` or `valid_until` is set | +| 6 | `issued_to` mismatch | `ECapabilityIssuedToMismatch` | Yes — only if `issued_to` is set | + +--- -- **Off-chain tracking is required.** Users must maintain a record of every - issued capability ID and its `valid_until` value so the correct ID can be - passed to `revoke_capability`. -- **Provide `valid_until` when revoking.** The stored value lets the denylist - entry be cleaned up automatically once it expires. -- **Call `cleanup_revoked_capabilities` periodically** to remove expired - entries and keep storage costs low. -- Capabilities revoked without a `valid_until` stay in the denylist until - explicitly destroyed. +## Managing Revoked Capabilities + +### The `revoked_capabilities` Denylist + +When a capability is revoked it is **not deleted from the chain** — the +on-chain `Capability` object still exists in the holder's wallet. Instead, +the capability's ID is added to a **denylist** stored inside the RoleMap +(`revoked_capabilities: LinkedTable`). Every call to +`assert_capability_valid` checks the denylist and rejects any capability whose +ID appears in it (error `ECapabilityHasBeenRevoked`). + +The denylist approach (as opposed to an allowlist of all issued capabilities) +was chosen deliberately: it keeps on-chain storage proportional to the number +of *currently revoked* capabilities rather than the total number ever issued. +This is important for deployments that issue large numbers of capabilities over +time. + +Each denylist entry maps a revoked capability ID to a `valid_until` timestamp +(Unix milliseconds). If the revoked capability had no `valid_until` field, the +stored value is `0`, which signals "no expiry — keep in the denylist +indefinitely". + +### How Time-Restricted Capabilities Affect Management + +Capabilities can carry optional `valid_from` and `valid_until` timestamps. +These fields are enforced by `assert_capability_valid`: a capability whose +time window has not yet started or has already passed is rejected with +`ECapabilityTimeConstraintsNotMet`, regardless of whether it appears in the +denylist. + +This has an important consequence for revocation: **once a capability's +`valid_until` timestamp has passed, the capability is naturally expired and +can no longer be used — even if it was never explicitly revoked.** Its +denylist entry therefore becomes redundant and can be safely removed. + +The `cleanup_revoked_capabilities` function exploits this property. It +iterates through the denylist and removes every entry whose stored +`valid_until` value is **non-zero** and **less than** the current clock time. +Entries with `valid_until == 0` (capabilities that were issued without an +expiry or where the revoker did not supply the `valid_until` value) are +kept because the corresponding capabilities never expire on their own. + +**Best practice:** always set a `valid_until` when issuing capabilities. +Even a generous validity window (e.g. one year) ensures that the +corresponding denylist entry can be automatically cleaned up after the +capability expires, rather than occupying storage indefinitely. + +### Off-Chain Tracking Requirements + +Because the RoleMap uses a denylist and not an allowlist, it does **not** +maintain an on-chain registry of all issued capabilities. Tracking every +issued capability on-chain would increase storage costs and slow down +validity checks. + +This design shifts the bookkeeping responsibility to the user: + +1. **Maintain an off-chain registry of every issued capability**, storing at + least the capability `ID`, the `role` it was issued for, the `issued_to` + address (if any), and the `valid_from` / `valid_until` timestamps. +2. **When revoking**, supply the correct capability ID and its `valid_until` + value (via the `cap_to_revoke_valid_until` parameter). The + `revoke_capability` function does **not** verify that the supplied ID + actually refers to a real, previously-issued capability — if you pass a + random ID, it will be silently added to the denylist without error. + Accurate off-chain records are therefore essential. +3. **Track which capabilities have been revoked or destroyed** so you do not + attempt to revoke the same capability twice (which would abort with + `ECapabilityToRevokeHasAlreadyBeenRevoked`). + +For deployments that only issue a small number of capabilities, a simplified +approach is acceptable: track only the issued capability IDs and pass +`None` for `cap_to_revoke_valid_until` when revoking. The trade-off is that +those denylist entries will never be automatically cleaned up — they persist +until the capability object is explicitly destroyed. + +### Cleaning Up the Denylist + +Over time the denylist can accumulate entries for capabilities that have +already naturally expired. The `cleanup_revoked_capabilities` function +removes these stale entries: + +1. It walks through every entry in the `revoked_capabilities` linked table. +2. For each entry with a **non-zero** `valid_until` value that is **less than** + the current on-chain clock time, the entry is removed. +3. Entries with `valid_until == 0` are skipped — they represent capabilities + that have no natural expiry and must remain on the denylist until the + capability object itself is destroyed (via `destroy_capability`). + +The cleanup operation requires a capability with the `RevokeCapabilities` +permission. + +**Recommendations for keeping the denylist short:** + +- Always provide the `valid_until` value when revoking a capability so that + the entry becomes eligible for automatic cleanup. +- Call `cleanup_revoked_capabilities` periodically (e.g. as a maintenance + transaction) to reclaim storage. +- When a revoked capability is no longer needed at all, have the holder call + `destroy_capability` to delete the on-chain object. Destroying a + capability also removes it from the denylist if it was listed there. --- From 4a049f79db00ac9c20a68d569efa6df3b99e2639 Mon Sep 17 00:00:00 2001 From: chrisgitiota Date: Tue, 7 Apr 2026 14:45:22 +0200 Subject: [PATCH 144/189] Reviewed and fixed extended `Capability Validation Rules` and `Managing Revoked Capabilities` sections --- .../src/core/types/RoleMap-README.md | 53 ++++++++++++++----- 1 file changed, 41 insertions(+), 12 deletions(-) diff --git a/audit-trail-rs/src/core/types/RoleMap-README.md b/audit-trail-rs/src/core/types/RoleMap-README.md index cbb5e756..456a491c 100644 --- a/audit-trail-rs/src/core/types/RoleMap-README.md +++ b/audit-trail-rs/src/core/types/RoleMap-README.md @@ -360,6 +360,24 @@ transferred. If `issued_to` is not set, any holder of the capability object may use it. +### 7 — `ERecordTagNotDefined` / `ERecordTagNotAllowed` + +This check is performed by the audit trail **after** all `RoleMap` checks +(1–6) have passed. It only applies to record operations (add, correct, +delete) that involve a tagged record. + +When a record carries a tag, two additional conditions must hold: + +1. The tag must be registered in the trail's **tag registry** + (`ERecordTagNotDefined`). +2. The role associated with the capability must include the tag in its + `RoleTags` allowlist (`ERecordTagNotAllowed`). A role without any + `RoleTags` is **not** permitted to operate on tagged records. + +If the record has no tag, this check is skipped. See +[Record Tags and RoleTags](#record-tags-and-roletags) for a full explanation +and examples. + ### Summary | # | Check | Error | Skippable | @@ -370,6 +388,7 @@ If `issued_to` is not set, any holder of the capability object may use it. | 4 | ID in revoked denylist | `ECapabilityHasBeenRevoked` | No | | 5 | Outside validity window | `ECapabilityTimeConstraintsNotMet` | Yes — only if `valid_from` or `valid_until` is set | | 6 | `issued_to` mismatch | `ECapabilityIssuedToMismatch` | Yes — only if `issued_to` is set | +| 7 | Record tag not allowed | `ERecordTagNotDefined` / `ERecordTagNotAllowed` | Yes — only for record operations on tagged records | --- @@ -379,9 +398,9 @@ If `issued_to` is not set, any holder of the capability object may use it. When a capability is revoked it is **not deleted from the chain** — the on-chain `Capability` object still exists in the holder's wallet. Instead, -the capability's ID is added to a **denylist** stored inside the RoleMap -(`revoked_capabilities: LinkedTable`). Every call to -`assert_capability_valid` checks the denylist and rejects any capability whose +the capability's ID is added to a **denylist** stored inside the audit trail. +During every call to an access restricted audit trail function, the internally +called `assert_capability_valid` function checks the denylist and rejects any capability whose ID appears in it (error `ECapabilityHasBeenRevoked`). The denylist approach (as opposed to an allowlist of all issued capabilities) @@ -398,7 +417,8 @@ indefinitely". ### How Time-Restricted Capabilities Affect Management Capabilities can carry optional `valid_from` and `valid_until` timestamps. -These fields are enforced by `assert_capability_valid`: a capability whose +These fields are enforced by the internally used `assert_capability_valid`: +a capability whose time window has not yet started or has already passed is rejected with `ECapabilityTimeConstraintsNotMet`, regardless of whether it appears in the denylist. @@ -412,8 +432,9 @@ The `cleanup_revoked_capabilities` function exploits this property. It iterates through the denylist and removes every entry whose stored `valid_until` value is **non-zero** and **less than** the current clock time. Entries with `valid_until == 0` (capabilities that were issued without an -expiry or where the revoker did not supply the `valid_until` value) are -kept because the corresponding capabilities never expire on their own. +expiry or where the revoker did not supply the `valid_until` value during the +`revoke_capability` call) are kept because the corresponding capabilities never +expire on their own. **Best practice:** always set a `valid_until` when issuing capabilities. Even a generous validity window (e.g. one year) ensures that the @@ -422,8 +443,8 @@ capability expires, rather than occupying storage indefinitely. ### Off-Chain Tracking Requirements -Because the RoleMap uses a denylist and not an allowlist, it does **not** -maintain an on-chain registry of all issued capabilities. Tracking every +Because the audit trail uses a denylist and not an allowlist, it does **not** +maintain an on-chain registry of all issued capabilities. Tracking every issued capability on-chain would increase storage costs and slow down validity checks. @@ -442,9 +463,16 @@ This design shifts the bookkeeping responsibility to the user: attempt to revoke the same capability twice (which would abort with `ECapabilityToRevokeHasAlreadyBeenRevoked`). +The off-chain capability registry can also be used to manage capability renewal: +when a capability is about to expire, a new capability is automatically issued for the +holder with an updated validity window. The old capability can be revoked or destroyed +at the same time. This process can be fully automated by a background service that +monitors capability expirations and performs renewals as needed. + For deployments that only issue a small number of capabilities, a simplified approach is acceptable: track only the issued capability IDs and pass -`None` for `cap_to_revoke_valid_until` when revoking. The trade-off is that +`None` for `cap_to_revoke_valid_until` when revoking capabilities using the +`revoke_capability` function. The trade-off is that those denylist entries will never be automatically cleaned up — they persist until the capability object is explicitly destroyed. @@ -466,12 +494,13 @@ permission. **Recommendations for keeping the denylist short:** -- Always provide the `valid_until` value when revoking a capability so that +- Always provide the `cap_to_revoke_valid_until` value that matches the `valid_until` of the + revoked capability when revoking a capability so that the entry becomes eligible for automatic cleanup. - Call `cleanup_revoked_capabilities` periodically (e.g. as a maintenance transaction) to reclaim storage. - When a revoked capability is no longer needed at all, have the holder call - `destroy_capability` to delete the on-chain object. Destroying a + `destroy_capability` to delete the on-chain object. Destroying a capability also removes it from the denylist if it was listed there. --- @@ -492,6 +521,6 @@ permission. Please note: * These constructors are just for convenience and do not enforce any invariants. For example, you could (not recommended) create a role named `NormalUser` with - `PermissionSet::admin_permissions()` + `PermissionSet::admin_permissions()`. * You can create custom permission sets by constructing a `PermissionSet` with an arbitrary combination of permissions. \ No newline at end of file From 317a43e00d07fe7e7f1f3a92448f1d27506beec0 Mon Sep 17 00:00:00 2001 From: Yasir Date: Wed, 8 Apr 2026 11:09:15 +0300 Subject: [PATCH 145/189] examples: expand audit trail walkthroughs --- audit-trail-move/Move.lock | 8 +- audit-trail-rs/src/core/types/role_map.rs | 6 +- examples/Cargo.toml | 48 +++ examples/audit-trail/01_create_audit_trail.rs | 2 +- .../audit-trail/02_add_and_read_records.rs | 142 +++++++ examples/audit-trail/03_update_metadata.rs | 88 +++++ examples/audit-trail/04_configure_locking.rs | 118 ++++++ examples/audit-trail/05_manage_access.rs | 110 ++++++ examples/audit-trail/06_delete_records.rs | 99 +++++ .../07_access_read_only_methods.rs | 100 +++++ examples/audit-trail/08_delete_audit_trail.rs | 75 ++++ examples/audit-trail/README.md | 24 +- .../audit-trail/advanced/09_tagged_records.rs | 105 +++++ .../advanced/10_capability_constraints.rs | 105 +++++ .../advanced/11_manage_record_tags.rs | 86 +++++ .../real-world/01_customs_clearance.rs | 301 +++++++++++++++ .../real-world/02_clinical_trial.rs | 365 ++++++++++++++++++ examples/audit-trail/run.sh | 12 + examples/utils/utils.rs | 12 +- 19 files changed, 1792 insertions(+), 14 deletions(-) create mode 100644 examples/audit-trail/02_add_and_read_records.rs create mode 100644 examples/audit-trail/03_update_metadata.rs create mode 100644 examples/audit-trail/04_configure_locking.rs create mode 100644 examples/audit-trail/05_manage_access.rs create mode 100644 examples/audit-trail/06_delete_records.rs create mode 100644 examples/audit-trail/07_access_read_only_methods.rs create mode 100644 examples/audit-trail/08_delete_audit_trail.rs create mode 100644 examples/audit-trail/advanced/09_tagged_records.rs create mode 100644 examples/audit-trail/advanced/10_capability_constraints.rs create mode 100644 examples/audit-trail/advanced/11_manage_record_tags.rs create mode 100644 examples/audit-trail/real-world/01_customs_clearance.rs create mode 100644 examples/audit-trail/real-world/02_clinical_trial.rs diff --git a/audit-trail-move/Move.lock b/audit-trail-move/Move.lock index 7715fcc0..cd3c8335 100644 --- a/audit-trail-move/Move.lock +++ b/audit-trail-move/Move.lock @@ -54,16 +54,16 @@ dependencies = [ ] [move.toolchain-version] -compiler-version = "1.19.1" +compiler-version = "1.20.0-rc" edition = "2024.beta" flavor = "iota" [env] [env.localnet] -chain-id = "fbd38c7a" -original-published-id = "0xf5318c775c59c55c99cd69f94c6b88e62dca9ea93b20fbb54d2a7728708a719f" -latest-published-id = "0xf5318c775c59c55c99cd69f94c6b88e62dca9ea93b20fbb54d2a7728708a719f" +chain-id = "417321d4" +original-published-id = "0xf0ec8b488113d6419ca5b1a1482f459b06db16f191ad10491dc1051374f1d2fe" +latest-published-id = "0xf0ec8b488113d6419ca5b1a1482f459b06db16f191ad10491dc1051374f1d2fe" published-version = "1" [env.testnet] diff --git a/audit-trail-rs/src/core/types/role_map.rs b/audit-trail-rs/src/core/types/role_map.rs index ff19f4de..cc66d963 100644 --- a/audit-trail-rs/src/core/types/role_map.rs +++ b/audit-trail-rs/src/core/types/role_map.rs @@ -17,6 +17,8 @@ use super::permission::Permission; use crate::core::internal::move_collections::{deserialize_vec_map, deserialize_vec_set}; use crate::core::internal::tx; use crate::error::Error; +use serde_aux::field_attributes::deserialize_option_number_from_string; + /// The role and capability registry attached to an audit trail. /// /// A [`RoleMap`] stores every named role defined on the trail, tracks which @@ -33,7 +35,7 @@ use crate::error::Error; /// ## What are Roles /// /// A role is a named set of [`Permission`]s, optionally paired with a [`RoleTags`] allowlist. -/// +/// /// Roles are identified by a unique string name within a trail (e.g., `"RecordAdmin"`, /// `"Auditor"`, `"LegalReviewer"`). The same role definition can back many independent /// [`Capability`] objects — to be owned and used by users or system components that should share the same @@ -302,9 +304,11 @@ pub struct Capability { pub issued_to: Option, /// Optional start of the validity window (Unix milliseconds). The /// capability is rejected before this timestamp. + #[serde(deserialize_with = "deserialize_option_number_from_string")] pub valid_from: Option, /// Optional end of the validity window (Unix milliseconds). The capability /// is rejected after this timestamp. + #[serde(deserialize_with = "deserialize_option_number_from_string")] pub valid_until: Option, } diff --git a/examples/Cargo.toml b/examples/Cargo.toml index c0595f62..9b68a7c1 100644 --- a/examples/Cargo.toml +++ b/examples/Cargo.toml @@ -52,6 +52,54 @@ path = "notarization/real-world/02_legal_contract.rs" name = "01_create_audit_trail" path = "audit-trail/01_create_audit_trail.rs" +[[example]] +name = "02_add_and_read_records" +path = "audit-trail/02_add_and_read_records.rs" + +[[example]] +name = "03_update_metadata" +path = "audit-trail/03_update_metadata.rs" + +[[example]] +name = "04_configure_locking" +path = "audit-trail/04_configure_locking.rs" + +[[example]] +name = "05_manage_access" +path = "audit-trail/05_manage_access.rs" + +[[example]] +name = "06_delete_records" +path = "audit-trail/06_delete_records.rs" + +[[example]] +name = "07_access_read_only_methods" +path = "audit-trail/07_access_read_only_methods.rs" + +[[example]] +name = "08_delete_audit_trail" +path = "audit-trail/08_delete_audit_trail.rs" + +[[example]] +name = "09_tagged_records" +path = "audit-trail/advanced/09_tagged_records.rs" + +[[example]] +name = "10_capability_constraints" +path = "audit-trail/advanced/10_capability_constraints.rs" + +[[example]] +name = "11_manage_record_tags" +path = "audit-trail/advanced/11_manage_record_tags.rs" + +[[example]] +name = "01_customs_clearance" +path = "audit-trail/real-world/01_customs_clearance.rs" + +[[example]] +name = "02_clinical_trial" +path = "audit-trail/real-world/02_clinical_trial.rs" + [dependencies] anyhow.workspace = true audit_trail = { path = "../audit-trail-rs" } diff --git a/examples/audit-trail/01_create_audit_trail.rs b/examples/audit-trail/01_create_audit_trail.rs index a35a295b..2fc44d76 100644 --- a/examples/audit-trail/01_create_audit_trail.rs +++ b/examples/audit-trail/01_create_audit_trail.rs @@ -109,4 +109,4 @@ async fn main() -> Result<()> { ); Ok(()) -} \ No newline at end of file +} diff --git a/examples/audit-trail/02_add_and_read_records.rs b/examples/audit-trail/02_add_and_read_records.rs new file mode 100644 index 00000000..d2a643c2 --- /dev/null +++ b/examples/audit-trail/02_add_and_read_records.rs @@ -0,0 +1,142 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use anyhow::{Result, ensure}; +use audit_trail::core::types::{CapabilityIssueOptions, Data, InitialRecord, PermissionSet}; +use examples::get_funded_audit_trail_client; +use product_common::core_client::CoreClient; + +/// Demonstrates how to: +/// 1. Create an audit trail with an initial record. +/// 2. Define a `RecordAdmin` role and issue a capability for it. +/// 3. Add follow-up records to the trail. +/// 4. Read records back individually and through paginated traversal. +#[tokio::main] +async fn main() -> Result<()> { + println!("=== Audit Trail: Add & Read Records ===\n"); + + let client = get_funded_audit_trail_client().await?; + println!("Client address: {}", client.sender_address()); + + // ------------------------------------------------------------------------- + // Step 1: Create a trail with one initial record + // ------------------------------------------------------------------------- + let created = client + .create_trail() + .with_initial_record(InitialRecord::new( + Data::text("Trail opened"), + Some("event:trail_created".to_string()), + None, + )) + .finish() + .build_and_execute(&client) + .await? + .output; + + let trail_id = created.trail_id; + let records = client.trail(trail_id).records(); + + println!("Trail created: {trail_id}\n"); + + // ------------------------------------------------------------------------- + // Step 2: Create a record-admin role and issue a capability for it + // ------------------------------------------------------------------------- + client + .trail(trail_id) + .access() + .for_role("RecordAdmin") + .create(PermissionSet::record_admin_permissions(), None) + .build_and_execute(&client) + .await?; + + let capability = client + .trail(trail_id) + .access() + .for_role("RecordAdmin") + .issue_capability(CapabilityIssueOptions::default()) + .build_and_execute(&client) + .await? + .output; + + println!( + "Issued capability {} for role {}\n", + capability.capability_id, capability.role + ); + + // ------------------------------------------------------------------------- + // Step 3: Append follow-up records + // ------------------------------------------------------------------------- + let first_added = records + .add( + Data::text("Shipment received at warehouse A"), + Some("event:received".to_string()), + None, + ) + .build_and_execute(&client) + .await? + .output; + + let second_added = records + .add( + Data::text("Shipment dispatched to retailer"), + Some("event:dispatched".to_string()), + None, + ) + .build_and_execute(&client) + .await? + .output; + + println!( + "Added records at sequence numbers {} and {}\n", + first_added.sequence_number, second_added.sequence_number + ); + + // ------------------------------------------------------------------------- + // Step 4: Read records back by sequence number + // ------------------------------------------------------------------------- + let initial = records.get(0).await?; + let first = records.get(first_added.sequence_number).await?; + let second = records.get(second_added.sequence_number).await?; + + println!("Initial record: {:?}", initial.data); + println!("First added record: {:?}", first.data); + println!("Second added record: {:?}\n", second.data); + + ensure!(matches!(initial.data, Data::Text(ref text) if text == "Trail opened")); + ensure!(matches!( + first.data, + Data::Text(ref text) if text == "Shipment received at warehouse A" + )); + ensure!(matches!( + second.data, + Data::Text(ref text) if text == "Shipment dispatched to retailer" + )); + + // ------------------------------------------------------------------------- + // Step 5: Inspect record count and page through the linked table + // ------------------------------------------------------------------------- + let count = records.record_count().await?; + println!("Current record count: {count}"); + ensure!(count == 3, "expected 3 records, got {count}"); + + let first_page = records.list_page(None, 2).await?; + println!( + "First page contains {} records; has_next_page = {}", + first_page.records.len(), + first_page.has_next_page + ); + + let second_page = records.list_page(first_page.next_cursor, 2).await?; + println!( + "Second page contains {} records; has_next_page = {}", + second_page.records.len(), + second_page.has_next_page + ); + + ensure!(first_page.records.len() == 2, "expected first page size 2"); + ensure!(second_page.records.len() == 1, "expected second page size 1"); + + println!("\nRecord flow completed successfully."); + + Ok(()) +} diff --git a/examples/audit-trail/03_update_metadata.rs b/examples/audit-trail/03_update_metadata.rs new file mode 100644 index 00000000..90b9efac --- /dev/null +++ b/examples/audit-trail/03_update_metadata.rs @@ -0,0 +1,88 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use anyhow::{Result, ensure}; +use audit_trail::core::types::{Data, ImmutableMetadata, InitialRecord, PermissionSet}; +use examples::get_funded_audit_trail_client; + +/// Demonstrates how to: +/// 1. Create a trail with immutable and updatable metadata. +/// 2. Delegate metadata updates through a dedicated `MetadataAdmin` role. +/// 3. Change and clear the trail's updatable metadata. +/// 4. Verify that immutable metadata never changes. +#[tokio::main] +async fn main() -> Result<()> { + println!("=== Audit Trail: Update Metadata ===\n"); + + let client = get_funded_audit_trail_client().await?; + + let immutable_metadata = ImmutableMetadata::new( + "Shipment Processing".to_string(), + Some("Tracks the lifecycle of a warehouse shipment".to_string()), + ); + + let created = client + .create_trail() + .with_trail_metadata(immutable_metadata.clone()) + .with_updatable_metadata("Status: Draft") + .with_initial_record(InitialRecord::new( + Data::text("Shipment created"), + Some("event:created".to_string()), + None, + )) + .finish() + .build_and_execute(&client) + .await? + .output; + + let trail = client.trail(created.trail_id); + + client + .trail(created.trail_id) + .access() + .for_role("MetadataAdmin") + .create(PermissionSet::metadata_admin_permissions(), None) + .build_and_execute(&client) + .await?; + + client + .trail(created.trail_id) + .access() + .for_role("MetadataAdmin") + .issue_capability(Default::default()) + .build_and_execute(&client) + .await?; + + let before = trail.get().await?; + println!( + "Before update:\n immutable = {:?}\n updatable = {:?}\n", + before.immutable_metadata, before.updatable_metadata + ); + + trail + .update_metadata(Some("Status: In Review".to_string())) + .build_and_execute(&client) + .await?; + + let after_update = trail.get().await?; + println!( + "After update:\n immutable = {:?}\n updatable = {:?}\n", + after_update.immutable_metadata, after_update.updatable_metadata + ); + + ensure!(after_update.immutable_metadata == Some(immutable_metadata.clone())); + ensure!(after_update.updatable_metadata.as_deref() == Some("Status: In Review")); + + trail.update_metadata(None).build_and_execute(&client).await?; + + let after_clear = trail.get().await?; + println!( + "After clear:\n immutable = {:?}\n updatable = {:?}", + after_clear.immutable_metadata, after_clear.updatable_metadata + ); + + ensure!(after_clear.immutable_metadata == Some(immutable_metadata)); + ensure!(after_clear.updatable_metadata.is_none()); + + Ok(()) +} diff --git a/examples/audit-trail/04_configure_locking.rs b/examples/audit-trail/04_configure_locking.rs new file mode 100644 index 00000000..931c3614 --- /dev/null +++ b/examples/audit-trail/04_configure_locking.rs @@ -0,0 +1,118 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use anyhow::{Result, ensure}; +use audit_trail::core::types::{Data, InitialRecord, LockingWindow, PermissionSet, TimeLock}; +use examples::get_funded_audit_trail_client; + +/// Demonstrates how to: +/// 1. Delegate locking updates through a `LockingAdmin` role. +/// 2. Freeze record creation with a write lock. +/// 3. Restore writes and add a new record. +/// 4. Update the delete-record window and delete-trail lock. +#[tokio::main] +async fn main() -> Result<()> { + println!("=== Audit Trail: Configure Locking ===\n"); + + let client = get_funded_audit_trail_client().await?; + + let created = client + .create_trail() + .with_initial_record(InitialRecord::new( + Data::text("Trail opened"), + Some("event:created".to_string()), + None, + )) + .finish() + .build_and_execute(&client) + .await? + .output; + + let trail = client.trail(created.trail_id); + + trail + .access() + .for_role("LockingAdmin") + .create(PermissionSet::locking_admin_permissions(), None) + .build_and_execute(&client) + .await?; + trail + .access() + .for_role("LockingAdmin") + .issue_capability(Default::default()) + .build_and_execute(&client) + .await?; + + trail + .access() + .for_role("RecordAdmin") + .create(PermissionSet::record_admin_permissions(), None) + .build_and_execute(&client) + .await?; + trail + .access() + .for_role("RecordAdmin") + .issue_capability(Default::default()) + .build_and_execute(&client) + .await?; + + trail + .locking() + .update_write_lock(TimeLock::Infinite) + .build_and_execute(&client) + .await?; + + let locked = trail.get().await?; + println!("Write lock after update: {:?}\n", locked.locking_config.write_lock); + ensure!(locked.locking_config.write_lock == TimeLock::Infinite); + + let blocked_add = trail + .records() + .add(Data::text("This write should fail"), None, None) + .build_and_execute(&client) + .await; + ensure!(blocked_add.is_err(), "write lock should block adding records"); + + trail + .locking() + .update_write_lock(TimeLock::None) + .build_and_execute(&client) + .await?; + + let added = trail + .records() + .add(Data::text("Write lock lifted"), Some("event:resumed".to_string()), None) + .build_and_execute(&client) + .await? + .output; + + println!( + "Added record {} after clearing the write lock.\n", + added.sequence_number + ); + + trail + .locking() + .update_delete_record_window(LockingWindow::CountBased { count: 2 }) + .build_and_execute(&client) + .await?; + trail + .locking() + .update_delete_trail_lock(TimeLock::Infinite) + .build_and_execute(&client) + .await?; + + let final_state = trail.get().await?; + println!( + "Final locking config:\n delete_record_window = {:?}\n delete_trail_lock = {:?}\n write_lock = {:?}", + final_state.locking_config.delete_record_window, + final_state.locking_config.delete_trail_lock, + final_state.locking_config.write_lock + ); + + ensure!(final_state.locking_config.delete_record_window == LockingWindow::CountBased { count: 2 }); + ensure!(final_state.locking_config.delete_trail_lock == TimeLock::Infinite); + ensure!(final_state.locking_config.write_lock == TimeLock::None); + + Ok(()) +} diff --git a/examples/audit-trail/05_manage_access.rs b/examples/audit-trail/05_manage_access.rs new file mode 100644 index 00000000..d8584c48 --- /dev/null +++ b/examples/audit-trail/05_manage_access.rs @@ -0,0 +1,110 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::HashSet; + +use anyhow::{Result, ensure}; +use audit_trail::core::types::{CapabilityIssueOptions, Data, Permission, PermissionSet}; +use examples::get_funded_audit_trail_client; +use product_common::core_client::CoreClient; + +/// Demonstrates how to: +/// 1. Create and update a custom role. +/// 2. Issue a constrained capability for that role. +/// 3. Revoke one capability and destroy another. +/// 4. Remove the role after its capabilities are no longer needed. +#[tokio::main] +async fn main() -> Result<()> { + println!("=== Audit Trail: Manage Access ===\n"); + + let client = get_funded_audit_trail_client().await?; + let sender = client.sender_address(); + + let created = client + .create_trail() + .with_initial_record(audit_trail::core::types::InitialRecord::new( + Data::text("Trail created"), + None, + None, + )) + .finish() + .build_and_execute(&client) + .await? + .output; + + let trail = client.trail(created.trail_id); + let role = trail.access().for_role("Operations"); + + let created_role = role + .create(PermissionSet::record_admin_permissions(), None) + .build_and_execute(&client) + .await? + .output; + println!("Created role: {}\n", created_role.role); + + let updated_permissions = PermissionSet { + permissions: HashSet::from([ + Permission::AddRecord, + Permission::DeleteRecord, + Permission::DeleteAllRecords, + ]), + }; + + let updated_role = role + .update_permissions(updated_permissions.clone(), None) + .build_and_execute(&client) + .await? + .output; + println!("Updated role permissions: {:?}\n", updated_role.permissions.permissions); + + let constrained_capability = role + .issue_capability(CapabilityIssueOptions { + issued_to: Some(sender), + valid_from_ms: None, + valid_until_ms: Some(4_102_444_800_000), + }) + .build_and_execute(&client) + .await? + .output; + + println!( + "Issued constrained capability:\n id = {}\n issued_to = {:?}\n valid_until = {:?}\n", + constrained_capability.capability_id, constrained_capability.issued_to, constrained_capability.valid_until + ); + + let on_chain = trail.get().await?; + let role_definition = on_chain.roles.roles.get("Operations").expect("role must exist"); + ensure!(role_definition.permissions == updated_permissions.permissions); + + trail + .access() + .revoke_capability(constrained_capability.capability_id, constrained_capability.valid_until) + .build_and_execute(&client) + .await?; + println!("Revoked capability {}\n", constrained_capability.capability_id); + + let disposable_capability = role + .issue_capability(Default::default()) + .build_and_execute(&client) + .await? + .output; + + trail + .access() + .destroy_capability(disposable_capability.capability_id) + .build_and_execute(&client) + .await?; + println!("Destroyed capability {}\n", disposable_capability.capability_id); + + role.delete().build_and_execute(&client).await?; + + let after_delete = trail.get().await?; + ensure!( + !after_delete.roles.roles.contains_key("Operations"), + "role should be removed from the trail" + ); + + println!("Removed the custom role after its capability lifecycle completed."); + + Ok(()) +} diff --git a/examples/audit-trail/06_delete_records.rs b/examples/audit-trail/06_delete_records.rs new file mode 100644 index 00000000..aa418788 --- /dev/null +++ b/examples/audit-trail/06_delete_records.rs @@ -0,0 +1,99 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::HashSet; + +use anyhow::{Result, ensure}; +use audit_trail::core::types::{CapabilityIssueOptions, Data, InitialRecord, Permission, PermissionSet}; +use examples::get_funded_audit_trail_client; + +/// Demonstrates how to: +/// 1. Create records using a delegated record-maintenance role. +/// 2. Delete a single record by sequence number. +/// 3. Delete the remaining records in one batch. +#[tokio::main] +async fn main() -> Result<()> { + println!("=== Audit Trail: Delete Records ===\n"); + + let client = get_funded_audit_trail_client().await?; + + let created = client + .create_trail() + .with_initial_record(InitialRecord::new( + Data::text("Initial record"), + Some("event:created".to_string()), + None, + )) + .finish() + .build_and_execute(&client) + .await? + .output; + + let trail = client.trail(created.trail_id); + let records = trail.records(); + + trail + .access() + .for_role("RecordMaintenance") + .create( + PermissionSet { + permissions: HashSet::from([ + Permission::AddRecord, + Permission::DeleteRecord, + Permission::DeleteAllRecords, + ]), + }, + None, + ) + .build_and_execute(&client) + .await?; + + trail + .access() + .for_role("RecordMaintenance") + .issue_capability(CapabilityIssueOptions::default()) + .build_and_execute(&client) + .await?; + + let added_one = records + .add(Data::text("Second record"), Some("event:received".to_string()), None) + .build_and_execute(&client) + .await? + .output; + let added_two = records + .add(Data::text("Third record"), Some("event:dispatched".to_string()), None) + .build_and_execute(&client) + .await? + .output; + + println!( + "Trail has records at sequence numbers 0, {}, {}\n", + added_one.sequence_number, added_two.sequence_number + ); + ensure!(records.record_count().await? == 3); + + let deleted_one = records + .delete(added_one.sequence_number) + .build_and_execute(&client) + .await? + .output; + println!("Deleted record {}\n", deleted_one.sequence_number); + + ensure!(records.record_count().await? == 2); + ensure!( + records.get(added_one.sequence_number).await.is_err(), + "deleted record should no longer be readable" + ); + + let deleted_remaining = records + .delete_records_batch(10) + .build_and_execute(&client) + .await? + .output; + + println!("Batch deleted the remaining {deleted_remaining} records."); + ensure!(deleted_remaining == 2); + ensure!(records.record_count().await? == 0); + + Ok(()) +} diff --git a/examples/audit-trail/07_access_read_only_methods.rs b/examples/audit-trail/07_access_read_only_methods.rs new file mode 100644 index 00000000..b32d182c --- /dev/null +++ b/examples/audit-trail/07_access_read_only_methods.rs @@ -0,0 +1,100 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use anyhow::{Result, ensure}; +use audit_trail::core::types::{ + Data, ImmutableMetadata, InitialRecord, LockingConfig, LockingWindow, PermissionSet, TimeLock, +}; +use examples::get_funded_audit_trail_client; + +/// Demonstrates how to: +/// 1. Load the full on-chain trail object. +/// 2. Inspect metadata, roles, and locking configuration. +/// 3. Read records individually and through pagination. +/// 4. Query the record-count and lock-status helpers. +#[tokio::main] +async fn main() -> Result<()> { + println!("=== Audit Trail: Read-Only Inspection ===\n"); + + let client = get_funded_audit_trail_client().await?; + + let created = client + .create_trail() + .with_trail_metadata(ImmutableMetadata::new( + "Operations Trail".to_string(), + Some("Used to inspect read-only accessors".to_string()), + )) + .with_updatable_metadata("Status: Active") + .with_locking_config(LockingConfig { + delete_record_window: LockingWindow::CountBased { count: 2 }, + delete_trail_lock: TimeLock::None, + write_lock: TimeLock::None, + }) + .with_initial_record(InitialRecord::new( + Data::text("Initial record"), + Some("event:created".to_string()), + None, + )) + .finish() + .build_and_execute(&client) + .await? + .output; + + let trail = client.trail(created.trail_id); + + trail + .access() + .for_role("RecordAdmin") + .create(PermissionSet::record_admin_permissions(), None) + .build_and_execute(&client) + .await?; + trail + .access() + .for_role("RecordAdmin") + .issue_capability(Default::default()) + .build_and_execute(&client) + .await?; + + trail + .records() + .add(Data::text("Follow-up record"), Some("event:updated".to_string()), None) + .build_and_execute(&client) + .await?; + + let on_chain = trail.get().await?; + println!( + "Trail summary:\n id = {}\n creator = {}\n created_at = {}\n sequence_number = {}\n immutable_metadata = {:?}\n updatable_metadata = {:?}\n", + on_chain.id.object_id(), + on_chain.creator, + on_chain.created_at, + on_chain.sequence_number, + on_chain.immutable_metadata, + on_chain.updatable_metadata + ); + + println!( + "Roles: {:?}\nLocking config: {:?}\n", + on_chain.roles.roles.keys().collect::>(), + on_chain.locking_config + ); + + let count = trail.records().record_count().await?; + let initial_record = trail.records().get(0).await?; + let first_page = trail.records().list_page(None, 10).await?; + let record_zero_locked = trail.locking().is_record_locked(0).await?; + + println!("Record count: {count}"); + println!("Record #0: {:?}", initial_record); + println!( + "First page size: {} (has_next_page = {})", + first_page.records.len(), + first_page.has_next_page + ); + println!("Is record #0 locked? {record_zero_locked}"); + + ensure!(count == 2); + ensure!(matches!(initial_record.data, Data::Text(ref text) if text == "Initial record")); + ensure!(first_page.records.len() == 2); + + Ok(()) +} diff --git a/examples/audit-trail/08_delete_audit_trail.rs b/examples/audit-trail/08_delete_audit_trail.rs new file mode 100644 index 00000000..041d064c --- /dev/null +++ b/examples/audit-trail/08_delete_audit_trail.rs @@ -0,0 +1,75 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::HashSet; + +use anyhow::{Result, ensure}; +use audit_trail::core::types::{CapabilityIssueOptions, Data, InitialRecord, Permission, PermissionSet}; +use examples::get_funded_audit_trail_client; + +/// Demonstrates how to: +/// 1. Show that a non-empty trail cannot be deleted. +/// 2. Empty the trail with `delete_records_batch`. +/// 3. Delete the trail once its records are gone. +#[tokio::main] +async fn main() -> Result<()> { + println!("=== Audit Trail: Delete Trail ===\n"); + + let client = get_funded_audit_trail_client().await?; + + let created = client + .create_trail() + .with_initial_record(InitialRecord::new( + Data::text("Initial record"), + Some("event:created".to_string()), + None, + )) + .finish() + .build_and_execute(&client) + .await? + .output; + + let trail = client.trail(created.trail_id); + + trail + .access() + .for_role("MaintenanceAdmin") + .create( + PermissionSet { + permissions: HashSet::from([Permission::DeleteAllRecords, Permission::DeleteAuditTrail]), + }, + None, + ) + .build_and_execute(&client) + .await?; + trail + .access() + .for_role("MaintenanceAdmin") + .issue_capability(CapabilityIssueOptions::default()) + .build_and_execute(&client) + .await?; + + let delete_while_non_empty = trail.delete_audit_trail().build_and_execute(&client).await; + ensure!(delete_while_non_empty.is_err(), "a trail must be empty before deletion"); + println!("Deleting the non-empty trail failed as expected.\n"); + + let deleted_records = trail + .records() + .delete_records_batch(10) + .build_and_execute(&client) + .await? + .output; + println!("Deleted {deleted_records} record(s) before trail removal.\n"); + + ensure!(trail.records().record_count().await? == 0); + + let deleted_trail = trail.delete_audit_trail().build_and_execute(&client).await?.output; + println!( + "Trail deleted:\n trail_id = {}\n timestamp = {}", + deleted_trail.trail_id, deleted_trail.timestamp + ); + + ensure!(trail.get().await.is_err(), "deleted trail should no longer be readable"); + + Ok(()) +} diff --git a/examples/audit-trail/README.md b/examples/audit-trail/README.md index 626f2a4a..e223f9b2 100644 --- a/examples/audit-trail/README.md +++ b/examples/audit-trail/README.md @@ -59,6 +59,28 @@ IOTA_AUDIT_TRAIL_PKG_ID=0x... IOTA_TF_COMPONENTS_PKG_ID=0x... cargo run --releas | Name | Information | | :-------------------------------------------------------------------------------------------------------------------------------------- | :-------------------------------------------------------------------------------------------------------------- | | [01_create_audit_trail](https://github.com/iotaledger/notarization/tree/main/examples/audit-trail/01_create_audit_trail.rs) | Creates an audit trail, defines a `RecordAdmin` role using the Admin capability, and issues a capability for it. | +| [02_add_and_read_records](https://github.com/iotaledger/notarization/tree/main/examples/audit-trail/02_add_and_read_records.rs) | Adds follow-up records to a trail, then loads them back individually and through paginated reads. | +| [03_update_metadata](https://github.com/iotaledger/notarization/tree/main/examples/audit-trail/03_update_metadata.rs) | Updates and clears the trail's mutable metadata while preserving immutable metadata. | +| [04_configure_locking](https://github.com/iotaledger/notarization/tree/main/examples/audit-trail/04_configure_locking.rs) | Configures write and delete locks, then shows how those rules affect record creation. | +| [05_manage_access](https://github.com/iotaledger/notarization/tree/main/examples/audit-trail/05_manage_access.rs) | Creates, updates, and deletes roles while issuing, revoking, and destroying capabilities. | +| [06_delete_records](https://github.com/iotaledger/notarization/tree/main/examples/audit-trail/06_delete_records.rs) | Deletes an individual record and then removes the remaining records in a batch. | +| [07_access_read_only_methods](https://github.com/iotaledger/notarization/tree/main/examples/audit-trail/07_access_read_only_methods.rs) | Reads back trail metadata, locking state, record counts, and paginated record data. | +| [08_delete_audit_trail](https://github.com/iotaledger/notarization/tree/main/examples/audit-trail/08_delete_audit_trail.rs) | Empties a trail and then deletes it, showing that non-empty trails cannot be removed. | + +## Advanced Examples + +| Name | Information | +| :-------------------------------------------------------------------------------------------------------------------------------------- | :-------------------------------------------------------------------------------------------------------------- | +| [09_tagged_records](https://github.com/iotaledger/notarization/tree/main/examples/audit-trail/advanced/09_tagged_records.rs) | Uses role tags and address-bound capabilities to restrict who may add tagged records. | +| [10_capability_constraints](https://github.com/iotaledger/notarization/tree/main/examples/audit-trail/advanced/10_capability_constraints.rs) | Shows address-bound capability use and how revocation immediately blocks future writes. | +| [11_manage_record_tags](https://github.com/iotaledger/notarization/tree/main/examples/audit-trail/advanced/11_manage_record_tags.rs) | Delegates record-tag administration and shows that in-use tags cannot be removed. | + +## 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. | ## Key Concepts @@ -144,4 +166,4 @@ Trails support three independent lock dimensions: - Delete-trail locks ensure data retention requirements are met - Revoking a capability adds it to the trail's revoked-capability registry, blocking future use -For more detailed information about IOTA Audit Trail concepts and advanced usage, refer to the official IOTA documentation. \ No newline at end of file +For more detailed information about IOTA Audit Trail concepts and advanced usage, refer to the official IOTA documentation. diff --git a/examples/audit-trail/advanced/09_tagged_records.rs b/examples/audit-trail/advanced/09_tagged_records.rs new file mode 100644 index 00000000..906f2711 --- /dev/null +++ b/examples/audit-trail/advanced/09_tagged_records.rs @@ -0,0 +1,105 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use anyhow::{Result, ensure}; +use audit_trail::core::types::{CapabilityIssueOptions, Data, InitialRecord, Permission, RoleTags}; +use examples::get_funded_audit_trail_client; +use product_common::core_client::CoreClient; + +/// Demonstrates how to: +/// 1. Create a trail with a predefined tag registry. +/// 2. Define a role that is restricted to one record tag. +/// 3. Issue a capability bound to a specific wallet address. +/// 4. Show that the holder can add only records matching the allowed tag. +#[tokio::main] +async fn main() -> Result<()> { + println!("=== Audit Trail Advanced: Tagged Records ===\n"); + + let admin = get_funded_audit_trail_client().await?; + let finance_writer = get_funded_audit_trail_client().await?; + + let created = admin + .create_trail() + .with_record_tags(["finance", "legal"]) + .with_initial_record(InitialRecord::new( + Data::text("Trail created"), + Some("event:created".to_string()), + None, + )) + .finish() + .build_and_execute(&admin) + .await? + .output; + + let trail_id = created.trail_id; + + admin + .trail(trail_id) + .access() + .for_role("FinanceWriter") + .create( + audit_trail::core::types::PermissionSet { + permissions: [Permission::AddRecord].into_iter().collect(), + }, + Some(RoleTags::new(["finance"])), + ) + .build_and_execute(&admin) + .await?; + + let issued = admin + .trail(trail_id) + .access() + .for_role("FinanceWriter") + .issue_capability(CapabilityIssueOptions { + issued_to: Some(finance_writer.sender_address()), + valid_from_ms: None, + valid_until_ms: None, + }) + .build_and_execute(&admin) + .await? + .output; + + println!( + "Issued FinanceWriter capability {} to {}\n", + issued.capability_id, + finance_writer.sender_address() + ); + + let finance_records = finance_writer.trail(trail_id).records(); + + let added = finance_records + .add( + Data::text("Invoice approved"), + Some("department:finance".to_string()), + Some("finance".to_string()), + ) + .build_and_execute(&finance_writer) + .await? + .output; + + println!( + "Added tagged record at sequence number {} with tag \"finance\".\n", + added.sequence_number + ); + + let wrong_tag = finance_records + .add( + Data::text("Legal review completed"), + Some("department:legal".to_string()), + Some("legal".to_string()), + ) + .build_and_execute(&finance_writer) + .await; + + ensure!( + wrong_tag.is_err(), + "a finance-scoped role must not add a legal-tagged record" + ); + + let finance_record = finance_records.get(added.sequence_number).await?; + println!("Stored tagged record: {:?}", finance_record); + + ensure!(finance_record.tag.as_deref() == Some("finance")); + + Ok(()) +} diff --git a/examples/audit-trail/advanced/10_capability_constraints.rs b/examples/audit-trail/advanced/10_capability_constraints.rs new file mode 100644 index 00000000..f2e6a968 --- /dev/null +++ b/examples/audit-trail/advanced/10_capability_constraints.rs @@ -0,0 +1,105 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use anyhow::{Result, ensure}; +use audit_trail::core::types::{CapabilityIssueOptions, Data, InitialRecord, PermissionSet}; +use examples::get_funded_audit_trail_client; +use product_common::core_client::CoreClient; + +/// Demonstrates how to: +/// 1. Bind a capability to a specific wallet address. +/// 2. Show that a different wallet cannot use it. +/// 3. Revoke the capability and confirm the bound holder can no longer use it. +#[tokio::main] +async fn main() -> Result<()> { + println!("=== Audit Trail Advanced: Capability Constraints ===\n"); + + let admin = get_funded_audit_trail_client().await?; + let intended_writer = get_funded_audit_trail_client().await?; + let wrong_writer = get_funded_audit_trail_client().await?; + + let created = admin + .create_trail() + .with_initial_record(InitialRecord::new(Data::text("Trail created"), None, None)) + .finish() + .build_and_execute(&admin) + .await? + .output; + + let trail_id = created.trail_id; + + admin + .trail(trail_id) + .access() + .for_role("RecordAdmin") + .create(PermissionSet::record_admin_permissions(), None) + .build_and_execute(&admin) + .await?; + + let issued = admin + .trail(trail_id) + .access() + .for_role("RecordAdmin") + .issue_capability(CapabilityIssueOptions { + issued_to: Some(intended_writer.sender_address()), + valid_from_ms: None, + valid_until_ms: None, + }) + .build_and_execute(&admin) + .await? + .output; + + println!( + "Issued capability {} to {}\n", + issued.capability_id, + intended_writer.sender_address() + ); + + let denied = wrong_writer + .trail(trail_id) + .records() + .add(Data::text("Wrong writer"), None, None) + .build_and_execute(&wrong_writer) + .await; + + ensure!( + denied.is_err(), + "a capability bound to another address must not be usable" + ); + + let added = intended_writer + .trail(trail_id) + .records() + .add(Data::text("Authorized writer"), None, None) + .build_and_execute(&intended_writer) + .await? + .output; + + println!("Bound holder added record {} successfully.\n", added.sequence_number); + + admin + .trail(trail_id) + .access() + .revoke_capability(issued.capability_id, issued.valid_until) + .build_and_execute(&admin) + .await?; + + let revoked_attempt = intended_writer + .trail(trail_id) + .records() + .add(Data::text("Should fail after revoke"), None, None) + .build_and_execute(&intended_writer) + .await; + + ensure!( + revoked_attempt.is_err(), + "revoked capabilities must no longer authorize record writes" + ); + + println!( + "Revoked capability {} and verified it can no longer be used.", + issued.capability_id + ); + + Ok(()) +} diff --git a/examples/audit-trail/advanced/11_manage_record_tags.rs b/examples/audit-trail/advanced/11_manage_record_tags.rs new file mode 100644 index 00000000..a4612dfb --- /dev/null +++ b/examples/audit-trail/advanced/11_manage_record_tags.rs @@ -0,0 +1,86 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use anyhow::{Result, ensure}; +use audit_trail::core::types::{CapabilityIssueOptions, Data, InitialRecord, PermissionSet, RoleTags}; +use examples::get_funded_audit_trail_client; + +/// Demonstrates how to: +/// 1. Delegate record-tag registry management to a `TagAdmin` role. +/// 2. Add and remove tags from the trail registry. +/// 3. Show that tags still in use by roles or records cannot be removed. +#[tokio::main] +async fn main() -> Result<()> { + println!("=== Audit Trail Advanced: Manage Record Tags ===\n"); + + let client = get_funded_audit_trail_client().await?; + + let created = client + .create_trail() + .with_record_tags(["finance"]) + .with_initial_record(InitialRecord::new(Data::text("Trail created"), None, None)) + .finish() + .build_and_execute(&client) + .await? + .output; + + let trail = client.trail(created.trail_id); + + trail + .access() + .for_role("TagAdmin") + .create(PermissionSet::tag_admin_permissions(), None) + .build_and_execute(&client) + .await?; + trail + .access() + .for_role("TagAdmin") + .issue_capability(Default::default()) + .build_and_execute(&client) + .await?; + + trail.tags().add("legal").build_and_execute(&client).await?; + + let after_add = trail.get().await?; + println!("Registry after adding \"legal\": {:?}\n", after_add.tags.tag_map); + ensure!(after_add.tags.contains_key("finance")); + ensure!(after_add.tags.contains_key("legal")); + + trail + .access() + .for_role("FinanceWriter") + .create( + PermissionSet::record_admin_permissions(), + Some(RoleTags::new(["finance"])), + ) + .build_and_execute(&client) + .await?; + trail + .access() + .for_role("FinanceWriter") + .issue_capability(CapabilityIssueOptions::default()) + .build_and_execute(&client) + .await?; + + trail + .records() + .add(Data::text("Tagged finance entry"), None, Some("finance".to_string())) + .build_and_execute(&client) + .await?; + + let remove_finance = trail.tags().remove("finance").build_and_execute(&client).await; + ensure!( + remove_finance.is_err(), + "a tag referenced by a role or record must not be removable" + ); + + trail.tags().remove("legal").build_and_execute(&client).await?; + + let after_remove = trail.get().await?; + println!("Registry after removing \"legal\": {:?}\n", after_remove.tags.tag_map); + + ensure!(after_remove.tags.contains_key("finance")); + ensure!(!after_remove.tags.contains_key("legal")); + + Ok(()) +} diff --git a/examples/audit-trail/real-world/01_customs_clearance.rs b/examples/audit-trail/real-world/01_customs_clearance.rs new file mode 100644 index 00000000..02586b57 --- /dev/null +++ b/examples/audit-trail/real-world/01_customs_clearance.rs @@ -0,0 +1,301 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +//! # Customs Clearance Example +//! +//! This example models a customs-clearance process for a single shipment. +//! +//! ## How the trail is used +//! +//! - `immutable_metadata`: shipment and declaration identity +//! - `updatable_metadata`: the current customs-processing status +//! - record tags: `documents`, `export`, `import`, and `inspection` +//! - roles and capabilities: each operational role writes only the events it owns +//! - locking: writes are frozen once the shipment is fully cleared + +use anyhow::{Result, ensure}; +use audit_trail::core::types::{ + CapabilityIssueOptions, Data, ImmutableMetadata, InitialRecord, LockingConfig, LockingWindow, PermissionSet, + RoleTags, TimeLock, +}; +use examples::get_funded_audit_trail_client; +use product_common::core_client::CoreClient; + +#[tokio::main] +async fn main() -> Result<()> { + println!("=== Customs Clearance ===\n"); + + let client = get_funded_audit_trail_client().await?; + + println!("Creating a customs-clearance trail..."); + + let created = client + .create_trail() + .with_record_tags(["documents", "export", "import", "inspection"]) + .with_trail_metadata(ImmutableMetadata::new( + "Shipment SHP-2026-CLEAR-001".to_string(), + Some("Route: Hamburg, Germany -> Nairobi, Kenya | Declaration: DEC-2026-44017".to_string()), + )) + .with_updatable_metadata("Status: Documents Pending") + .with_locking_config(LockingConfig { + delete_record_window: LockingWindow::CountBased { count: 2 }, + delete_trail_lock: TimeLock::None, + write_lock: TimeLock::None, + }) + .with_initial_record(InitialRecord::new( + Data::text("Customs clearance case opened for inbound shipment"), + Some("event:case_opened".to_string()), + Some("documents".to_string()), + )) + .finish() + .build_and_execute(&client) + .await? + .output; + + let trail_id = created.trail_id; + + issue_tagged_record_role(&client, trail_id, "DocsOperator", "documents", client.sender_address()).await?; + issue_tagged_record_role(&client, trail_id, "ExportBroker", "export", client.sender_address()).await?; + issue_tagged_record_role(&client, trail_id, "ImportBroker", "import", client.sender_address()).await?; + + client + .trail(trail_id) + .access() + .for_role("Supervisor") + .create(PermissionSet::metadata_admin_permissions(), None) + .build_and_execute(&client) + .await?; + client + .trail(trail_id) + .access() + .for_role("Supervisor") + .issue_capability(CapabilityIssueOptions { + issued_to: Some(client.sender_address()), + valid_from_ms: None, + valid_until_ms: None, + }) + .build_and_execute(&client) + .await?; + + client + .trail(trail_id) + .access() + .for_role("LockingAdmin") + .create(PermissionSet::locking_admin_permissions(), None) + .build_and_execute(&client) + .await?; + client + .trail(trail_id) + .access() + .for_role("LockingAdmin") + .issue_capability(CapabilityIssueOptions { + issued_to: Some(client.sender_address()), + valid_from_ms: None, + valid_until_ms: None, + }) + .build_and_execute(&client) + .await?; + + let docs_uploaded = client + .trail(trail_id) + .records() + .add( + Data::text("Commercial invoice and packing list uploaded"), + Some("event:documents_uploaded".to_string()), + Some("documents".to_string()), + ) + .build_and_execute(&client) + .await? + .output; + + println!("Docs operator added record #{}.\n", docs_uploaded.sequence_number); + + client + .trail(trail_id) + .update_metadata(Some("Status: Awaiting Export Clearance".to_string())) + .build_and_execute(&client) + .await?; + + let export_filed = client + .trail(trail_id) + .records() + .add( + Data::text("Export declaration filed with German customs"), + Some("event:export_declaration_filed".to_string()), + Some("export".to_string()), + ) + .build_and_execute(&client) + .await? + .output; + + let export_cleared = client + .trail(trail_id) + .records() + .add( + Data::text("Export clearance granted by Hamburg customs office"), + Some("event:export_cleared".to_string()), + Some("export".to_string()), + ) + .build_and_execute(&client) + .await? + .output; + + println!( + "Export broker added records #{} and #{}.\n", + export_filed.sequence_number, export_cleared.sequence_number + ); + + client + .trail(trail_id) + .update_metadata(Some("Status: Awaiting Import Clearance".to_string())) + .build_and_execute(&client) + .await?; + + let denied_inspection = client + .trail(trail_id) + .records() + .add( + Data::text("Import broker attempted to record an inspection result"), + Some("event:invalid_inspection_write".to_string()), + Some("inspection".to_string()), + ) + .build_and_execute(&client) + .await; + + ensure!( + denied_inspection.is_err(), + "inspection-tagged writes should fail before an inspection-scoped capability exists" + ); + println!("Inspection write was correctly denied before the inspector role existed.\n"); + + issue_tagged_record_role(&client, trail_id, "Inspector", "inspection", client.sender_address()).await?; + + let inspection_done = client + .trail(trail_id) + .records() + .add( + Data::text("Customs inspection completed with no discrepancies"), + Some("event:inspection_completed".to_string()), + Some("inspection".to_string()), + ) + .build_and_execute(&client) + .await? + .output; + + println!("Inspector added record #{}.\n", inspection_done.sequence_number); + + let duty_assessed = client + .trail(trail_id) + .records() + .add( + Data::text("Import duty assessed and paid"), + Some("event:duty_assessed".to_string()), + Some("import".to_string()), + ) + .build_and_execute(&client) + .await? + .output; + + let import_cleared = client + .trail(trail_id) + .records() + .add( + Data::text("Import clearance granted by Nairobi customs"), + Some("event:import_cleared".to_string()), + Some("import".to_string()), + ) + .build_and_execute(&client) + .await? + .output; + + println!( + "Import broker added records #{} and #{}.\n", + duty_assessed.sequence_number, import_cleared.sequence_number + ); + + client + .trail(trail_id) + .update_metadata(Some("Status: Cleared".to_string())) + .build_and_execute(&client) + .await?; + + client + .trail(trail_id) + .locking() + .update_write_lock(TimeLock::Infinite) + .build_and_execute(&client) + .await?; + + let after_lock = client.trail(trail_id).get().await?; + println!( + "Write lock after clearance: {:?}\n", + after_lock.locking_config.write_lock + ); + + let late_note = client + .trail(trail_id) + .records() + .add( + Data::text("Late customs note after the case was closed"), + Some("event:late_note".to_string()), + Some("documents".to_string()), + ) + .build_and_execute(&client) + .await; + + ensure!( + late_note.is_err(), + "cleared customs trail should reject late writes after the final lock" + ); + + let trail = client.trail(trail_id); + let first_page = trail.records().list_page(None, 20).await?; + + println!("Recorded customs events:"); + for (sequence_number, record) in &first_page.records { + println!( + " #{} | {:?} | tag={:?} | {:?}", + sequence_number, record.data, record.tag, record.metadata + ); + } + + ensure!(first_page.records.len() == 6, "expected 6 customs records"); + ensure!( + trail.get().await?.updatable_metadata.as_deref() == Some("Status: Cleared"), + "customs case should finish in cleared state" + ); + + println!("\nCustoms clearance completed successfully."); + + Ok(()) +} + +async fn issue_tagged_record_role( + client: &audit_trail::AuditTrailClient, + trail_id: iota_interaction::types::base_types::ObjectID, + role_name: &str, + tag: &str, + issued_to: iota_interaction::types::base_types::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 new file mode 100644 index 00000000..e1931291 --- /dev/null +++ b/examples/audit-trail/real-world/02_clinical_trial.rs @@ -0,0 +1,365 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +//! # Clinical Trial Data-Integrity Example +//! +//! This example models a Phase III clinical trial where an immutable audit trail +//! guarantees data integrity, role-scoped access, and time-constrained oversight. +//! +//! ## How the trail is used +//! +//! - `immutable_metadata`: protocol identity and study description +//! - `updatable_metadata`: current study phase (updated as the trial progresses) +//! - record tags: `enrollment`, `safety`, `efficacy`, `pk` (added mid-study) +//! - roles and capabilities: each role writes only its designated tag +//! - time-constrained capabilities: Monitor access is windowed to the study period +//! - locking: a deletion window protects recent records; a time-lock freezes the +//! dataset after the Data Safety Board completes its review +//! - read-only verification: a regulator inspects the trail without write access + +use anyhow::{Result, ensure}; +use audit_trail::core::types::{ + CapabilityIssueOptions, Data, ImmutableMetadata, InitialRecord, LockingConfig, LockingWindow, PermissionSet, + RoleTags, TimeLock, +}; +use examples::get_funded_audit_trail_client; +use product_common::core_client::CoreClient; + +#[tokio::main] +async fn main() -> Result<()> { + println!("=== Clinical Trial Data Integrity ===\n"); + + let client = get_funded_audit_trail_client().await?; + + // ----------------------------------------------------------------------- + // 1. Create the trial trail + // ----------------------------------------------------------------------- + println!("Creating the clinical-trial audit trail..."); + + let created = client + .create_trail() + .with_record_tags(["enrollment", "safety", "efficacy"]) + .with_trail_metadata(ImmutableMetadata::new( + "Protocol CTR-2026-03742".to_string(), + Some("Phase III: Efficacy of Drug X vs Placebo in Moderate-to-Severe Asthma".to_string()), + )) + .with_updatable_metadata("Phase: Enrollment") + .with_locking_config(LockingConfig { + delete_record_window: LockingWindow::CountBased { count: 3 }, + delete_trail_lock: TimeLock::None, + write_lock: TimeLock::None, + }) + .with_initial_record(InitialRecord::new( + Data::text("Clinical trial CTR-2026-03742 opened for enrollment"), + Some("event:trial_opened".to_string()), + Some("enrollment".to_string()), + )) + .finish() + .build_and_execute(&client) + .await? + .output; + + let trail_id = created.trail_id; + println!("Trail created with ID {trail_id}\n"); + + // ----------------------------------------------------------------------- + // 2. Define roles with tag-scoped permissions + // ----------------------------------------------------------------------- + println!("Defining study roles..."); + + issue_tagged_record_role(&client, trail_id, "Enroller", "enrollment", client.sender_address()).await?; + issue_tagged_record_role(&client, trail_id, "SafetyOfficer", "safety", client.sender_address()).await?; + issue_tagged_record_role( + &client, + trail_id, + "EfficacyReviewer", + "efficacy", + client.sender_address(), + ) + .await?; + + // Monitor can update metadata (study phase) but only during the study window + client + .trail(trail_id) + .access() + .for_role("Monitor") + .create(PermissionSet::metadata_admin_permissions(), None) + .build_and_execute(&client) + .await?; + + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_millis() as u64; + // Monitor access is valid for 90 days from now + let study_end_ms = now_ms + 90 * 24 * 60 * 60 * 1000; + + client + .trail(trail_id) + .access() + .for_role("Monitor") + .issue_capability(CapabilityIssueOptions { + issued_to: Some(client.sender_address()), + valid_from_ms: Some(now_ms), + valid_until_ms: Some(study_end_ms), + }) + .build_and_execute(&client) + .await?; + + println!("Monitor capability issued (valid for 90 days from now, ends at timestamp {study_end_ms})\n"); + + // Data Safety Board can manage locking + client + .trail(trail_id) + .access() + .for_role("DataSafetyBoard") + .create(PermissionSet::locking_admin_permissions(), None) + .build_and_execute(&client) + .await?; + client + .trail(trail_id) + .access() + .for_role("DataSafetyBoard") + .issue_capability(CapabilityIssueOptions { + issued_to: Some(client.sender_address()), + valid_from_ms: None, + valid_until_ms: None, + }) + .build_and_execute(&client) + .await?; + + // ----------------------------------------------------------------------- + // 3. Enrollment phase — add enrollment records + // ----------------------------------------------------------------------- + println!("--- Enrollment Phase ---"); + + let enrolled = client + .trail(trail_id) + .records() + .add( + Data::text("Patient P-101 enrolled at Site Hamburg"), + Some("event:patient_enrolled".to_string()), + Some("enrollment".to_string()), + ) + .build_and_execute(&client) + .await? + .output; + println!("Enroller added record #{}.\n", enrolled.sequence_number); + + // ----------------------------------------------------------------------- + // 4. Add safety and efficacy records + // ----------------------------------------------------------------------- + println!("--- Study Data Collection ---"); + + let safety_event = client + .trail(trail_id) + .records() + .add( + Data::text("Adverse event: mild headache reported by Patient P-101"), + Some("event:adverse_event".to_string()), + Some("safety".to_string()), + ) + .build_and_execute(&client) + .await? + .output; + + let efficacy_record = client + .trail(trail_id) + .records() + .add( + Data::text("Week 12: FEV1 improvement of 320 mL over baseline for P-101"), + Some("event:efficacy_observed".to_string()), + Some("efficacy".to_string()), + ) + .build_and_execute(&client) + .await? + .output; + + println!( + "SafetyOfficer added record #{}, EfficacyReviewer added record #{}.\n", + safety_event.sequence_number, efficacy_record.sequence_number + ); + + // ----------------------------------------------------------------------- + // 5. Add a new tag mid-study (pharmacokinetics) + // ----------------------------------------------------------------------- + println!("--- Mid-Study Amendment ---"); + + client + .trail(trail_id) + .tags() + .add("pk") + .build_and_execute(&client) + .await?; + println!("Added tag 'pk' (pharmacokinetics) to the trail."); + + // Now create a role for the new tag + issue_tagged_record_role(&client, trail_id, "PkAnalyst", "pk", client.sender_address()).await?; + + let pk_record = client + .trail(trail_id) + .records() + .add( + Data::text("PK analysis: Cmax reached at 2.4 h, half-life 8.7 h"), + Some("event:pk_result".to_string()), + Some("pk".to_string()), + ) + .build_and_execute(&client) + .await? + .output; + println!("PkAnalyst added record #{}.\n", pk_record.sequence_number); + + // ----------------------------------------------------------------------- + // 6. Deletion window protects recent records + // ----------------------------------------------------------------------- + println!("--- Deletion Window Enforcement ---"); + + let delete_attempt = client + .trail(trail_id) + .records() + .delete(pk_record.sequence_number) + .build_and_execute(&client) + .await; + + ensure!( + delete_attempt.is_err(), + "recent records must be protected by the count-based deletion window" + ); + println!( + "Record #{} is within the deletion window (newest 3) and cannot be deleted.\n", + pk_record.sequence_number + ); + + // ----------------------------------------------------------------------- + // 7. Monitor updates study phase metadata + // ----------------------------------------------------------------------- + println!("--- Metadata Update ---"); + + client + .trail(trail_id) + .update_metadata(Some("Phase: Data Review".to_string())) + .build_and_execute(&client) + .await?; + + let trail = client.trail(trail_id).get().await?; + println!("Study phase updated to: {:?}\n", trail.updatable_metadata); + + // ----------------------------------------------------------------------- + // 8. Data Safety Board locks the study dataset + // ----------------------------------------------------------------------- + println!("--- Data Safety Board Lock ---"); + + // Lock writes until a specific future timestamp (e.g. 1 year from now), + // then the dataset becomes permanently locked. + let lock_until_ms = now_ms + 365 * 24 * 60 * 60 * 1000; // 1 year from now + + client + .trail(trail_id) + .locking() + .update_write_lock(TimeLock::UnlockAtMs(lock_until_ms)) + .build_and_execute(&client) + .await?; + + let locked_trail = client.trail(trail_id).get().await?; + println!( + "Write lock set to UnlockAtMs({}) — writes blocked until that timestamp.\n", + lock_until_ms + ); + println!("Current locking config: {:?}\n", locked_trail.locking_config); + + // Also lock the trail from deletion permanently. + client + .trail(trail_id) + .locking() + .update_delete_trail_lock(TimeLock::Infinite) + .build_and_execute(&client) + .await?; + + let final_locking = client.trail(trail_id).get().await?; + println!( + "Delete-trail lock set to {:?} — trail cannot be deleted.\n", + final_locking.locking_config.delete_trail_lock + ); + + // ----------------------------------------------------------------------- + // 9. Regulator read-only verification + // ----------------------------------------------------------------------- + println!("--- Regulator Verification ---"); + + // In production the regulator would use AuditTrailClientReadOnly (no signer), + // but for this example we reuse the funded client to demonstrate read-only methods. + let regulator_handle = client.trail(trail_id); + + let on_chain = regulator_handle.get().await?; + println!("Protocol: {:?}", on_chain.immutable_metadata); + println!("Phase: {:?}", on_chain.updatable_metadata); + println!("Roles: {:?}", on_chain.roles.roles.keys().collect::>()); + println!("Tags: {:?}", on_chain.tags.tag_map.keys().collect::>()); + + let first_page = regulator_handle.records().list_page(None, 20).await?; + println!("\nVerified records ({} total):", first_page.records.len()); + for (seq, record) in &first_page.records { + println!(" #{} | tag={:?} | {:?}", seq, record.tag, record.metadata); + } + + // ----------------------------------------------------------------------- + // 10. Assertions + // ----------------------------------------------------------------------- + ensure!( + first_page.records.len() == 5, + "expected 5 records (initial + enrolled + safety + efficacy + pk)" + ); + ensure!( + on_chain.tags.tag_map.contains_key("pk"), + "the 'pk' tag must exist after mid-study amendment" + ); + ensure!( + on_chain.locking_config.delete_record_window == LockingWindow::CountBased { count: 3 }, + "deletion window must remain count-based with count 3" + ); + ensure!( + on_chain.locking_config.delete_trail_lock == TimeLock::Infinite, + "delete-trail lock must be Infinite" + ); + ensure!( + matches!(on_chain.locking_config.write_lock, TimeLock::UnlockAtMs(_)), + "write lock must be UnlockAtMs" + ); + ensure!( + on_chain.updatable_metadata.as_deref() == Some("Phase: Data Review"), + "study phase must be 'Data Review'" + ); + + println!("\nClinical trial data-integrity verification completed successfully."); + + Ok(()) +} + +async fn issue_tagged_record_role( + client: &audit_trail::AuditTrailClient, + trail_id: iota_interaction::types::base_types::ObjectID, + role_name: &str, + tag: &str, + issued_to: iota_interaction::types::base_types::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/run.sh b/examples/audit-trail/run.sh index 529a9983..d6c68a03 100755 --- a/examples/audit-trail/run.sh +++ b/examples/audit-trail/run.sh @@ -20,6 +20,18 @@ echo "================================" examples=( "01_create_audit_trail" + "02_add_and_read_records" + "03_update_metadata" + "04_configure_locking" + "05_manage_access" + "06_delete_records" + "07_access_read_only_methods" + "08_delete_audit_trail" + "09_tagged_records" + "10_capability_constraints" + "11_manage_record_tags" + "01_customs_clearance" + "02_clinical_trial" ) for example in "${examples[@]}"; do diff --git a/examples/utils/utils.rs b/examples/utils/utils.rs index a6bcee64..1c330329 100644 --- a/examples/utils/utils.rs +++ b/examples/utils/utils.rs @@ -2,9 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 use anyhow::Context; -use iota_interaction::types::base_types::ObjectID; use audit_trail::{AuditTrailClient, PackageOverrides}; -use iota_sdk::{IotaClientBuilder, IOTA_LOCAL_NETWORK_URL}; +use iota_interaction::types::base_types::ObjectID; +use iota_sdk::{IOTA_LOCAL_NETWORK_URL, IotaClientBuilder}; use notarization::client::{NotarizationClient, NotarizationClientReadOnly}; use product_common::test_utils::{InMemSigner, request_funds}; @@ -16,7 +16,7 @@ async fn get_iota_client() -> anyhow::Result { .map_err(|err| anyhow::anyhow!("failed to connect to network; {}", err)) } -fn get_package_id_from_env(env_var_name: &str) -> anyhow::Result { +fn get_package_id_from_env(env_var_name: &str) -> anyhow::Result { let value = std::env::var(env_var_name) .with_context(|| format!("env variable '{env_var_name}' must be set in order to run the examples"))?; @@ -51,11 +51,9 @@ pub async fn get_funded_notarization_client() -> Result Result, anyhow::Error> { let iota_client = get_iota_client().await?; - let audit_trail_pkg_id = - get_package_id_from_env("IOTA_AUDIT_TRAIL_PKG_ID")?; + let audit_trail_pkg_id = get_package_id_from_env("IOTA_AUDIT_TRAIL_PKG_ID")?; - let tf_components_pkg_id = - get_package_id_from_env("IOTA_TF_COMPONENTS_PKG_ID")?; + let tf_components_pkg_id = get_package_id_from_env("IOTA_TF_COMPONENTS_PKG_ID")?; let signer = InMemSigner::new(); let sender_address = signer.get_address().await?; From 60715a7ce5fd9e8481644fabe1d96fbf1758c97e Mon Sep 17 00:00:00 2001 From: Yasir Date: Wed, 8 Apr 2026 14:14:34 +0300 Subject: [PATCH 146/189] feat: add audit trail wasm examples and browser tests --- .github/workflows/build-and-test.yml | 54 + audit-trail-move/Move.lock | 4 +- .../src/core/internal/capability.rs | 10 +- .../wasm/audit_trail_wasm/cypress.config.ts | 33 + .../wasm/audit_trail_wasm/cypress/Dockerfile | 27 + .../audit_trail_wasm/cypress/app/index.html | 24 + .../cypress/app/package-lock.json | 3286 +++++++++++++++++ .../audit_trail_wasm/cypress/app/package.json | 20 + .../cypress/app/src/audit_trail.ts | 18 + .../audit_trail_wasm/cypress/app/src/main.ts | 3 + .../cypress/app/src/vite-env.d.ts | 1 + .../cypress/app/tsconfig.json | 27 + .../cypress/app/vite.config.js | 38 + .../audit_trail_wasm/cypress/e2e/tests.cy.js | 35 + .../wasm/audit_trail_wasm/examples/README.md | 40 +- .../examples/src/01_create_audit_trail.ts | 44 + .../examples/src/01_create_trail.ts | 21 - ..._records.ts => 02_add_and_read_records.ts} | 25 +- .../examples/src/02_fetch_trail.ts | 19 - .../examples/src/03_update_metadata.ts | 68 + .../examples/src/04_configure_locking.ts | 99 + .../examples/src/04_delete_records_batch.ts | 28 - .../examples/src/05_manage_access.ts | 98 + .../examples/src/06_delete_records.ts | 88 + .../src/07_access_read_only_methods.ts | 76 + .../examples/src/08_delete_audit_trail.ts | 78 + .../src/advanced/09_tagged_records.ts | 71 + .../src/advanced/10_capability_constraints.ts | 93 + .../src/advanced/11_manage_record_tags.ts | 89 + .../audit_trail_wasm/examples/src/main.ts | 55 +- .../src/real-world/01_customs_clearance.ts | 252 ++ .../src/real-world/02_clinical_trial.ts | 263 ++ .../audit_trail_wasm/examples/src/tests.ts | 65 +- .../audit_trail_wasm/examples/src/web-main.ts | 61 +- .../wasm/audit_trail_wasm/lib/tsconfig.json | 2 +- .../audit_trail_wasm/lib/tsconfig.web.json | 2 +- bindings/wasm/audit_trail_wasm/package.json | 12 +- examples/audit-trail/05_manage_access.rs | 9 +- examples/audit-trail/README.md | 2 +- .../real-world/01_customs_clearance.rs | 5 +- notarization-move/Move.history.json | 8 +- 41 files changed, 5099 insertions(+), 154 deletions(-) create mode 100644 bindings/wasm/audit_trail_wasm/cypress.config.ts create mode 100644 bindings/wasm/audit_trail_wasm/cypress/Dockerfile create mode 100644 bindings/wasm/audit_trail_wasm/cypress/app/index.html create mode 100644 bindings/wasm/audit_trail_wasm/cypress/app/package-lock.json create mode 100644 bindings/wasm/audit_trail_wasm/cypress/app/package.json create mode 100644 bindings/wasm/audit_trail_wasm/cypress/app/src/audit_trail.ts create mode 100644 bindings/wasm/audit_trail_wasm/cypress/app/src/main.ts create mode 100644 bindings/wasm/audit_trail_wasm/cypress/app/src/vite-env.d.ts create mode 100644 bindings/wasm/audit_trail_wasm/cypress/app/tsconfig.json create mode 100644 bindings/wasm/audit_trail_wasm/cypress/app/vite.config.js create mode 100644 bindings/wasm/audit_trail_wasm/cypress/e2e/tests.cy.js create mode 100644 bindings/wasm/audit_trail_wasm/examples/src/01_create_audit_trail.ts delete mode 100644 bindings/wasm/audit_trail_wasm/examples/src/01_create_trail.ts rename bindings/wasm/audit_trail_wasm/examples/src/{03_add_and_list_records.ts => 02_add_and_read_records.ts} (66%) delete mode 100644 bindings/wasm/audit_trail_wasm/examples/src/02_fetch_trail.ts create mode 100644 bindings/wasm/audit_trail_wasm/examples/src/03_update_metadata.ts create mode 100644 bindings/wasm/audit_trail_wasm/examples/src/04_configure_locking.ts delete mode 100644 bindings/wasm/audit_trail_wasm/examples/src/04_delete_records_batch.ts create mode 100644 bindings/wasm/audit_trail_wasm/examples/src/05_manage_access.ts create mode 100644 bindings/wasm/audit_trail_wasm/examples/src/06_delete_records.ts create mode 100644 bindings/wasm/audit_trail_wasm/examples/src/07_access_read_only_methods.ts create mode 100644 bindings/wasm/audit_trail_wasm/examples/src/08_delete_audit_trail.ts create mode 100644 bindings/wasm/audit_trail_wasm/examples/src/advanced/09_tagged_records.ts create mode 100644 bindings/wasm/audit_trail_wasm/examples/src/advanced/10_capability_constraints.ts create mode 100644 bindings/wasm/audit_trail_wasm/examples/src/advanced/11_manage_record_tags.ts create mode 100644 bindings/wasm/audit_trail_wasm/examples/src/real-world/01_customs_clearance.ts create mode 100644 bindings/wasm/audit_trail_wasm/examples/src/real-world/02_clinical_trial.ts diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 99863cbb..e2035e39 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -368,3 +368,57 @@ jobs: - name: Run cypress run: docker run --network host cypress-test test:browser:${{ matrix.browser }} + + test-wasm-browser-audit-trail: + needs: build-wasm-audit-trail + if: ${{ needs.check-for-run-condition.outputs.should-run == 'true' }} + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + browser: [chrome, firefox] + + steps: + - uses: actions/checkout@v3 + + - name: Set up Node.js + uses: actions/setup-node@v1 + with: + node-version: 20.x + + - name: Install JS dependencies + run: npm ci + working-directory: bindings/wasm/audit_trail_wasm + + - name: Download bindings/wasm/audit_trail_wasm artifacts + uses: actions/download-artifact@v4 + with: + name: audit-trail-wasm-bindings-build + path: bindings/wasm/audit_trail_wasm + + - name: Start iota sandbox + uses: "./.github/actions/iota/setup" + with: + iota-version: ${{ env.IOTA_VERSION }} + + - name: publish Audit Trail Move package + run: | + eval "$(./publish_package.sh)" + echo "IOTA_AUDIT_TRAIL_PKG_ID=$IOTA_AUDIT_TRAIL_PKG_ID" >> "$GITHUB_ENV" + echo "IOTA_TF_COMPONENTS_PKG_ID=$IOTA_TF_COMPONENTS_PKG_ID" >> "$GITHUB_ENV" + working-directory: audit-trail-move/scripts/ + + - name: Build Docker image + uses: docker/build-push-action@v6.2.0 + with: + context: bindings/wasm/ + file: bindings/wasm/audit_trail_wasm/cypress/Dockerfile + push: false + tags: cypress-audit-trail:latest + load: true + build-args: | + IOTA_AUDIT_TRAIL_PKG_ID=${{ env.IOTA_AUDIT_TRAIL_PKG_ID }} + IOTA_TF_COMPONENTS_PKG_ID=${{ env.IOTA_TF_COMPONENTS_PKG_ID }} + + - name: Run cypress + run: docker run --network host cypress-audit-trail test:browser:${{ matrix.browser }} diff --git a/audit-trail-move/Move.lock b/audit-trail-move/Move.lock index cd3c8335..b36d8ec9 100644 --- a/audit-trail-move/Move.lock +++ b/audit-trail-move/Move.lock @@ -62,8 +62,8 @@ flavor = "iota" [env.localnet] chain-id = "417321d4" -original-published-id = "0xf0ec8b488113d6419ca5b1a1482f459b06db16f191ad10491dc1051374f1d2fe" -latest-published-id = "0xf0ec8b488113d6419ca5b1a1482f459b06db16f191ad10491dc1051374f1d2fe" +original-published-id = "0x70e6f4f0ba0d3fae15288bf254b34829ad3c122b7f73766b13233af6acaeb715" +latest-published-id = "0x70e6f4f0ba0d3fae15288bf254b34829ad3c122b7f73766b13233af6acaeb715" published-version = "1" [env.testnet] diff --git a/audit-trail-rs/src/core/internal/capability.rs b/audit-trail-rs/src/core/internal/capability.rs index 440a043d..0fded2b6 100644 --- a/audit-trail-rs/src/core/internal/capability.rs +++ b/audit-trail-rs/src/core/internal/capability.rs @@ -1,11 +1,11 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use std::collections::{BTreeMap, HashSet}; +use std::collections::HashSet; use iota_interaction::move_types::language_storage::StructTag; use iota_interaction::rpc_types::{ - IotaMoveStruct, IotaMoveValue, IotaObjectDataFilter, IotaObjectDataOptions, IotaObjectResponseQuery, IotaParsedData, + IotaObjectDataFilter, IotaObjectDataOptions, IotaObjectResponseQuery, IotaParsedData, }; use iota_interaction::types::TypeTag; use iota_interaction::types::base_types::{IotaAddress, ObjectID, ObjectRef}; @@ -129,11 +129,7 @@ where table.id, DynamicFieldName { type_: TypeTag::Struct(Box::new(ID::type_())), - value: IotaMoveStruct::WithFields(BTreeMap::from([( - "bytes".to_string(), - IotaMoveValue::Address(IotaAddress::from(key)), - )])) - .to_json_value(), + value: serde_json::Value::String(IotaAddress::from(key).to_string()), }, ) .await?; diff --git a/bindings/wasm/audit_trail_wasm/cypress.config.ts b/bindings/wasm/audit_trail_wasm/cypress.config.ts new file mode 100644 index 00000000..1f1d784a --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/cypress.config.ts @@ -0,0 +1,33 @@ +import { defineConfig } from "cypress"; + +export default defineConfig({ + screenshotOnRunFailure: false, + video: false, + requestTimeout: 10000, + defaultCommandTimeout: 60000, + retries: { + runMode: 3, + }, + e2e: { + baseUrl: "http://localhost:5173", + supportFile: false, + setupNodeEvents(on, config) { + on("before:browser:launch", (browser, launchOptions) => { + if (browser.family === "firefox") { + // Fix to make subtle crypto work in cypress firefox + // https://github.com/cypress-io/cypress/issues/18217 + launchOptions.preferences[ + "network.proxy.testing_localhost_is_secure_when_hijacked" + ] = true; + // Temporary fix to allow cypress to control Firefox via CDP + // https://github.com/cypress-io/cypress/issues/29713 + // https://fxdx.dev/deprecating-cdp-support-in-firefox-embracing-the-future-with-webdriver-bidi/ + launchOptions.preferences[ + "remote.active-protocols" + ] = 3; + } + return launchOptions; + }); + }, + }, +}); \ No newline at end of file diff --git a/bindings/wasm/audit_trail_wasm/cypress/Dockerfile b/bindings/wasm/audit_trail_wasm/cypress/Dockerfile new file mode 100644 index 00000000..c1b0cd24 --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/cypress/Dockerfile @@ -0,0 +1,27 @@ +FROM cypress/browsers:latest + +ARG IOTA_AUDIT_TRAIL_PKG_ID + +ENV IOTA_AUDIT_TRAIL_PKG_ID=$IOTA_AUDIT_TRAIL_PKG_ID + +ARG IOTA_TF_COMPONENTS_PKG_ID + +ENV IOTA_TF_COMPONENTS_PKG_ID=$IOTA_TF_COMPONENTS_PKG_ID + +ARG NETWORK_NAME_FAUCET + +ENV NETWORK_NAME_FAUCET=$NETWORK_NAME_FAUCET + +ARG NETWORK_URL + +ENV NETWORK_URL=$NETWORK_URL + +COPY ./ /e2e + +WORKDIR /e2e/audit_trail_wasm + +RUN npm ci + +RUN npm run build:examples:web + +ENTRYPOINT [ "npm", "run" ] \ No newline at end of file diff --git a/bindings/wasm/audit_trail_wasm/cypress/app/index.html b/bindings/wasm/audit_trail_wasm/cypress/app/index.html new file mode 100644 index 00000000..5d4406c0 --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/cypress/app/index.html @@ -0,0 +1,24 @@ + + + + + + + Audit Trail Example App + + +
+ + + \ No newline at end of file diff --git a/bindings/wasm/audit_trail_wasm/cypress/app/package-lock.json b/bindings/wasm/audit_trail_wasm/cypress/app/package-lock.json new file mode 100644 index 00000000..ffaa7de8 --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/cypress/app/package-lock.json @@ -0,0 +1,3286 @@ +{ + "name": "vite-project", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "vite-project", + "version": "0.0.0", + "dependencies": { + "@iota/audit-trail": "file:../..", + "@iota/iota-sdk": "^1.0.0" + }, + "devDependencies": { + "typescript": "~5.7.2", + "vite": "^6.2.0", + "vite-plugin-node-polyfills": "^0.24.0" + } + }, + "../..": { + "name": "@iota/audit-trail", + "version": "0.1.0-alpha", + "license": "Apache-2.0", + "dependencies": { + "@iota/iota-interaction-ts": "^0.12.0" + }, + "devDependencies": { + "@types/mocha": "^9.1.0", + "@types/node": "^22.0.0", + "cypress": "^14.2.0", + "dprint": "^0.33.0", + "mocha": "^9.2.0", + "rimraf": "^6.0.1", + "start-server-and-test": "^2.0.11", + "ts-mocha": "^9.0.2", + "ts-node": "^10.9.2", + "tsconfig-paths": "^4.1.0", + "typedoc": "^0.28.5", + "typedoc-plugin-markdown": "^4.4.1", + "typescript": "^5.7.3", + "wasm-opt": "^1.4.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@iota/iota-sdk": "^1.11.0" + } + }, + "node_modules/@0no-co/graphql.web": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@0no-co/graphql.web/-/graphql.web-1.2.0.tgz", + "integrity": "sha512-/1iHy9TTr63gE1YcR5idjx8UREz1s0kFhydf3bBLCXyqjhkIc6igAzTOx3zPifCwFR87tsh/4Pa9cNts6d2otw==", + "license": "MIT", + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0" + }, + "peerDependenciesMeta": { + "graphql": { + "optional": true + } + } + }, + "node_modules/@0no-co/graphqlsp": { + "version": "1.15.3", + "resolved": "https://registry.npmjs.org/@0no-co/graphqlsp/-/graphqlsp-1.15.3.tgz", + "integrity": "sha512-rap58Wh1qbRnGpPGwB60P6rvKF6G+mgo1kPeDySWIAcqkGMjuyQdrZPcHS6w7mKOT8i/f1UQmjow6+7vfuEXKw==", + "license": "MIT", + "dependencies": { + "@gql.tada/internal": "^1.0.0", + "graphql": "^15.5.0 || ^16.0.0 || ^17.0.0" + }, + "peerDependencies": { + "graphql": "^15.5.0 || ^16.0.0 || ^17.0.0", + "typescript": "^5.0.0 || ^6.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@gql.tada/cli-utils": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@gql.tada/cli-utils/-/cli-utils-1.7.3.tgz", + "integrity": "sha512-3iQY5E/jvv3Lnh6D1Mh7zr+Bb9C/TGk1DHkm+lbIjQBnZAu2m+BcTcr1e3spUt6Aa6HG/xAN2XxpbWw9oZALEg==", + "license": "MIT", + "dependencies": { + "@0no-co/graphqlsp": "^1.12.13", + "@gql.tada/internal": "1.0.9", + "graphql": "^15.5.0 || ^16.0.0 || ^17.0.0" + }, + "peerDependencies": { + "@0no-co/graphqlsp": "^1.12.13", + "@gql.tada/svelte-support": "1.0.2", + "@gql.tada/vue-support": "1.0.2", + "graphql": "^15.5.0 || ^16.0.0 || ^17.0.0", + "typescript": "^5.0.0 || ^6.0.0" + }, + "peerDependenciesMeta": { + "@gql.tada/svelte-support": { + "optional": true + }, + "@gql.tada/vue-support": { + "optional": true + } + } + }, + "node_modules/@gql.tada/internal": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@gql.tada/internal/-/internal-1.0.9.tgz", + "integrity": "sha512-Bp8yi+kLrzIJ3l5Dfxhz48H4OCH2LCX+pShaPcJgh+oiBt6clrjUKDYNDD3Z78aDQ3+Tyrxe4dd0MfLgpSLPPg==", + "license": "MIT", + "dependencies": { + "@0no-co/graphql.web": "^1.0.5" + }, + "peerDependencies": { + "graphql": "^15.5.0 || ^16.0.0 || ^17.0.0", + "typescript": "^5.0.0 || ^6.0.0" + } + }, + "node_modules/@graphql-typed-document-node/core": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", + "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", + "license": "MIT", + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@iota/audit-trail": { + "resolved": "../..", + "link": true + }, + "node_modules/@iota/bcs": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@iota/bcs/-/bcs-1.5.0.tgz", + "integrity": "sha512-/hv395YtUcRNLY00v7Cl2O+KvVUaUajg4OucZENgSE4Xu1ygUGsLD3dU5FixOUVOn7Abo+n7+KYr9PE/1dsvWg==", + "license": "Apache-2.0", + "dependencies": { + "@scure/base": "^1.2.4" + } + }, + "node_modules/@iota/iota-sdk": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@iota/iota-sdk/-/iota-sdk-1.11.0.tgz", + "integrity": "sha512-Fveg/4euheaBUzU1ybPyFGe7sSfLFUjLNHhPjNFUmSBOMR+l9q3LU1QdN2sLElcmgJZ+BLxAEmL8TZ0eX3Khpw==", + "license": "Apache-2.0", + "dependencies": { + "@graphql-typed-document-node/core": "^3.2.0", + "@iota/bcs": "1.5.0", + "@noble/curves": "^1.4.2", + "@noble/hashes": "^1.4.0", + "@scure/base": "^1.2.4", + "@scure/bip32": "^1.4.0", + "@scure/bip39": "^1.3.0", + "bignumber.js": "^9.1.1", + "gql.tada": "^1.8.2", + "graphql": "^16.9.0", + "valibot": "^1.2.0" + }, + "engines": { + "node": ">=24" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@rollup/plugin-inject": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@rollup/plugin-inject/-/plugin-inject-5.0.5.tgz", + "integrity": "sha512-2+DEJbNBoPROPkgTDNe8/1YXWcqxbN5DTjASVIOx8HS+pITXushyNiBV56RB08zuptzz8gT3YfkqriTBVycepg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@scure/base": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", + "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz", + "integrity": "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==", + "license": "MIT", + "dependencies": { + "@noble/curves": "~1.9.0", + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz", + "integrity": "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/asn1.js": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-4.10.1.tgz", + "integrity": "sha512-p32cOF5q0Zqs9uBiONKYLm6BClCoBCM5O9JfeUSlnQLBTxYdTK+pW+nXflm8UkKd2UYlEbYz5qEi0JuZR9ckSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0" + } + }, + "node_modules/asn1.js/node_modules/bn.js": { + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/assert": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/assert/-/assert-2.1.0.tgz", + "integrity": "sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "is-nan": "^1.3.2", + "object-is": "^1.1.5", + "object.assign": "^4.1.4", + "util": "^0.12.5" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/bn.js": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.3.tgz", + "integrity": "sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w==", + "dev": true, + "license": "MIT" + }, + "node_modules/brorand": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", + "integrity": "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==", + "dev": true, + "license": "MIT" + }, + "node_modules/browser-resolve": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/browser-resolve/-/browser-resolve-2.0.0.tgz", + "integrity": "sha512-7sWsQlYL2rGLy2IWm8WL8DCTJvYLc/qlOnsakDac87SOoCd16WLsaAMdCiAqsTNHIe+SXfaqyxyo6THoWqs8WQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve": "^1.17.0" + } + }, + "node_modules/browserify-aes": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/browserify-aes/-/browserify-aes-1.2.0.tgz", + "integrity": "sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-xor": "^1.0.3", + "cipher-base": "^1.0.0", + "create-hash": "^1.1.0", + "evp_bytestokey": "^1.0.3", + "inherits": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/browserify-cipher": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/browserify-cipher/-/browserify-cipher-1.0.1.tgz", + "integrity": "sha512-sPhkz0ARKbf4rRQt2hTpAHqn47X3llLkUGn+xEJzLjwY8LRs2p0v7ljvI5EyoRO/mexrNunNECisZs+gw2zz1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserify-aes": "^1.0.4", + "browserify-des": "^1.0.0", + "evp_bytestokey": "^1.0.0" + } + }, + "node_modules/browserify-des": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/browserify-des/-/browserify-des-1.0.2.tgz", + "integrity": "sha512-BioO1xf3hFwz4kc6iBhI3ieDFompMhrMlnDFC4/0/vd5MokpuAc3R+LYbwTA9A5Yc9pq9UYPqffKpW2ObuwX5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "cipher-base": "^1.0.1", + "des.js": "^1.0.0", + "inherits": "^2.0.1", + "safe-buffer": "^5.1.2" + } + }, + "node_modules/browserify-rsa": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/browserify-rsa/-/browserify-rsa-4.1.1.tgz", + "integrity": "sha512-YBjSAiTqM04ZVei6sXighu679a3SqWORA3qZTEqZImnlkDIFtKc6pNutpjyZ8RJTjQtuYfeetkxM11GwoYXMIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bn.js": "^5.2.1", + "randombytes": "^2.1.0", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/browserify-sign": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/browserify-sign/-/browserify-sign-4.2.5.tgz", + "integrity": "sha512-C2AUdAJg6rlM2W5QMp2Q4KGQMVBwR1lIimTsUnutJ8bMpW5B52pGpR2gEnNBNwijumDo5FojQ0L9JrXA8m4YEw==", + "dev": true, + "license": "ISC", + "dependencies": { + "bn.js": "^5.2.2", + "browserify-rsa": "^4.1.1", + "create-hash": "^1.2.0", + "create-hmac": "^1.1.7", + "elliptic": "^6.6.1", + "inherits": "^2.0.4", + "parse-asn1": "^5.1.9", + "readable-stream": "^2.3.8", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/browserify-sign/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/browserify-sign/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/browserify-sign/node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/browserify-sign/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/browserify-sign/node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/browserify-zlib": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz", + "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pako": "~1.0.5" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-xor": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/buffer-xor/-/buffer-xor-1.0.3.tgz", + "integrity": "sha512-571s0T7nZWK6vB67HI5dyUF7wXiNcfaPPPTl6zYCNApANjIvYJTg7hlud/+cJpdAhS7dVzqMLmfhfHR3rAcOjQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/builtin-status-codes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz", + "integrity": "sha512-HpGFw18DgFWlncDfjTa2rcQ4W88O1mC8e8yZ2AvQY5KDaktSTwo+KRf6nHK6FRI5FyRyb/5T6+TSxfP7QyGsmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/cipher-base": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.7.tgz", + "integrity": "sha512-Mz9QMT5fJe7bKI7MH31UilT5cEK5EHHRCccw/YRFsRY47AuNgaV6HY3rscp0/I4Q+tTW/5zoqpSeRRI54TkDWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.2" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/console-browserify": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/console-browserify/-/console-browserify-1.2.0.tgz", + "integrity": "sha512-ZMkYO/LkF17QvCPqM0gxw8yUzigAOZOSWSHg91FH6orS7vcEj5dVZTidN2fQ14yBSdg97RqhSNwLUXInd52OTA==", + "dev": true + }, + "node_modules/constants-browserify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/constants-browserify/-/constants-browserify-1.0.0.tgz", + "integrity": "sha512-xFxOwqIzR/e1k1gLiWEophSCMqXcwVHIH7akf7b/vxcUeGunlj3hvZaaqxwHsTgn+IndtkQJgSztIDWeumWJDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/create-ecdh": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.4.tgz", + "integrity": "sha512-mf+TCx8wWc9VpuxfP2ht0iSISLZnt0JgWlrOKZiNqyUZWnjIaCIVNQArMHnCZKfEYRg6IM7A+NeJoN8gf/Ws0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "bn.js": "^4.1.0", + "elliptic": "^6.5.3" + } + }, + "node_modules/create-ecdh/node_modules/bn.js": { + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/create-hash": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz", + "integrity": "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cipher-base": "^1.0.1", + "inherits": "^2.0.1", + "md5.js": "^1.3.4", + "ripemd160": "^2.0.1", + "sha.js": "^2.4.0" + } + }, + "node_modules/create-hmac": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/create-hmac/-/create-hmac-1.1.7.tgz", + "integrity": "sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cipher-base": "^1.0.3", + "create-hash": "^1.1.0", + "inherits": "^2.0.1", + "ripemd160": "^2.0.0", + "safe-buffer": "^5.0.1", + "sha.js": "^2.4.8" + } + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/crypto-browserify": { + "version": "3.12.1", + "resolved": "https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-3.12.1.tgz", + "integrity": "sha512-r4ESw/IlusD17lgQi1O20Fa3qNnsckR126TdUuBgAu7GBYSIPvdNyONd3Zrxh0xCwA4+6w/TDArBPsMvhur+KQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserify-cipher": "^1.0.1", + "browserify-sign": "^4.2.3", + "create-ecdh": "^4.0.4", + "create-hash": "^1.2.0", + "create-hmac": "^1.1.7", + "diffie-hellman": "^5.0.3", + "hash-base": "~3.0.4", + "inherits": "^2.0.4", + "pbkdf2": "^3.1.2", + "public-encrypt": "^4.0.3", + "randombytes": "^2.1.0", + "randomfill": "^1.0.4" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/des.js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.1.0.tgz", + "integrity": "sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0" + } + }, + "node_modules/diffie-hellman": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz", + "integrity": "sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "bn.js": "^4.1.0", + "miller-rabin": "^4.0.0", + "randombytes": "^2.0.0" + } + }, + "node_modules/diffie-hellman/node_modules/bn.js": { + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/domain-browser": { + "version": "4.22.0", + "resolved": "https://registry.npmjs.org/domain-browser/-/domain-browser-4.22.0.tgz", + "integrity": "sha512-IGBwjF7tNk3cwypFNH/7bfzBcgSCbaMOD3GsaY1AU/JRrnHnYgEM0+9kQt52iZxjNsjBtJYtao146V+f8jFZNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/elliptic": { + "version": "6.6.1", + "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.6.1.tgz", + "integrity": "sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "bn.js": "^4.11.9", + "brorand": "^1.1.0", + "hash.js": "^1.0.0", + "hmac-drbg": "^1.0.1", + "inherits": "^2.0.4", + "minimalistic-assert": "^1.0.1", + "minimalistic-crypto-utils": "^1.0.1" + } + }, + "node_modules/elliptic/node_modules/bn.js": { + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/evp_bytestokey": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz", + "integrity": "sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "md5.js": "^1.3.4", + "safe-buffer": "^5.1.1" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gql.tada": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/gql.tada/-/gql.tada-1.9.2.tgz", + "integrity": "sha512-QxRHVpxtrOVdYXz6oavq0lBM+Zdp0swapLGJcD4SLpXDcsD337BHDFrzqqjfkbepv0sSAiO0LGabu1kI5D5Gyg==", + "license": "MIT", + "dependencies": { + "@0no-co/graphql.web": "^1.0.5", + "@0no-co/graphqlsp": "^1.12.13", + "@gql.tada/cli-utils": "1.7.3", + "@gql.tada/internal": "1.0.9" + }, + "bin": { + "gql-tada": "bin/cli.js", + "gql.tada": "bin/cli.js" + }, + "peerDependencies": { + "typescript": "^5.0.0 || ^6.0.0" + } + }, + "node_modules/graphql": { + "version": "16.13.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.2.tgz", + "integrity": "sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==", + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hash-base": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.0.5.tgz", + "integrity": "sha512-vXm0l45VbcHEVlTCzs8M+s0VeYsB2lnlAaThoLKGXr3bE/VWDOelNUnycUPEhKEaXARL2TEFjBOyUiM6+55KBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/hash.js": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", + "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hmac-drbg": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", + "integrity": "sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "hash.js": "^1.0.3", + "minimalistic-assert": "^1.0.0", + "minimalistic-crypto-utils": "^1.0.1" + } + }, + "node_modules/https-browserify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/https-browserify/-/https-browserify-1.0.0.tgz", + "integrity": "sha512-J+FkSdyD+0mA0N+81tMotaRMfSL9SGi+xpD3T6YApKsc3bGSXJlfXri3VyFOeYkfLRQisDk1W+jIFFKBeUBbBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-nan": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/is-nan/-/is-nan-1.3.2.tgz", + "integrity": "sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.0", + "define-properties": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isomorphic-timers-promises": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/isomorphic-timers-promises/-/isomorphic-timers-promises-1.0.1.tgz", + "integrity": "sha512-u4sej9B1LPSxTGKB/HiuzvEQnXH0ECYkSVQU39koSwmFAxhlEAFl9RdTvLv4TOTQUgBS5O3O5fwUxk6byBZ+IQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/md5.js": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", + "integrity": "sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "hash-base": "^3.0.0", + "inherits": "^2.0.1", + "safe-buffer": "^5.1.2" + } + }, + "node_modules/miller-rabin": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/miller-rabin/-/miller-rabin-4.0.1.tgz", + "integrity": "sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "brorand": "^1.0.1" + }, + "bin": { + "miller-rabin": "bin/miller-rabin" + } + }, + "node_modules/miller-rabin/node_modules/bn.js": { + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "dev": true, + "license": "ISC" + }, + "node_modules/minimalistic-crypto-utils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz", + "integrity": "sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-stdlib-browser": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/node-stdlib-browser/-/node-stdlib-browser-1.3.1.tgz", + "integrity": "sha512-X75ZN8DCLftGM5iKwoYLA3rjnrAEs97MkzvSd4q2746Tgpg8b8XWiBGiBG4ZpgcAqBgtgPHTiAc8ZMCvZuikDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assert": "^2.0.0", + "browser-resolve": "^2.0.0", + "browserify-zlib": "^0.2.0", + "buffer": "^5.7.1", + "console-browserify": "^1.1.0", + "constants-browserify": "^1.0.0", + "create-require": "^1.1.1", + "crypto-browserify": "^3.12.1", + "domain-browser": "4.22.0", + "events": "^3.0.0", + "https-browserify": "^1.0.0", + "isomorphic-timers-promises": "^1.0.1", + "os-browserify": "^0.3.0", + "path-browserify": "^1.0.1", + "pkg-dir": "^5.0.0", + "process": "^0.11.10", + "punycode": "^1.4.1", + "querystring-es3": "^0.2.1", + "readable-stream": "^3.6.0", + "stream-browserify": "^3.0.0", + "stream-http": "^3.2.0", + "string_decoder": "^1.0.0", + "timers-browserify": "^2.0.4", + "tty-browserify": "0.0.1", + "url": "^0.11.4", + "util": "^0.12.4", + "vm-browserify": "^1.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-is": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", + "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/os-browserify": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/os-browserify/-/os-browserify-0.3.0.tgz", + "integrity": "sha512-gjcpUc3clBf9+210TRaDWbf+rZZZEshZ+DlXMRCeAjp0xhTrnQsKHypIy1J3d5hKdUzj69t708EHtU8P6bUn0A==", + "dev": true, + "license": "MIT" + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "dev": true, + "license": "(MIT AND Zlib)" + }, + "node_modules/parse-asn1": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/parse-asn1/-/parse-asn1-5.1.9.tgz", + "integrity": "sha512-fIYNuZ/HastSb80baGOuPRo1O9cf4baWw5WsAp7dBuUzeTD/BoaG8sVTdlPFksBE2lF21dN+A1AnrpIjSWqHHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "asn1.js": "^4.10.1", + "browserify-aes": "^1.2.0", + "evp_bytestokey": "^1.0.3", + "pbkdf2": "^3.1.5", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/pbkdf2": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.5.tgz", + "integrity": "sha512-Q3CG/cYvCO1ye4QKkuH7EXxs3VC/rI1/trd+qX2+PolbaKG0H+bgcZzrTt96mMyRtejk+JMCiLUn3y29W8qmFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "create-hash": "^1.2.0", + "create-hmac": "^1.1.7", + "ripemd160": "^2.0.3", + "safe-buffer": "^5.2.1", + "sha.js": "^2.4.12", + "to-buffer": "^1.2.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkg-dir": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-5.0.0.tgz", + "integrity": "sha512-NPE8TDbzl/3YQYY7CSS228s3g2ollTFnc+Qi3tqmqJp9Vg2ovUpixcJEo2HJScN2Ez+kEaal6y70c0ehqJBJeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^5.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.9", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", + "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/public-encrypt": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/public-encrypt/-/public-encrypt-4.0.3.tgz", + "integrity": "sha512-zVpa8oKZSz5bTMTFClc1fQOnyyEzpl5ozpi1B5YcvBrdohMjH2rfsBtyXcuNuwjsDIXmBYlF2N5FlJYhR29t8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "bn.js": "^4.1.0", + "browserify-rsa": "^4.0.0", + "create-hash": "^1.1.0", + "parse-asn1": "^5.0.0", + "randombytes": "^2.0.1", + "safe-buffer": "^5.1.2" + } + }, + "node_modules/public-encrypt/node_modules/bn.js": { + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/punycode": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", + "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/querystring-es3": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/querystring-es3/-/querystring-es3-0.2.1.tgz", + "integrity": "sha512-773xhDQnZBMFobEiztv8LIl70ch5MSF/jUQVlhwFyBILqq96anmoctVIYz+ZRp0qbCKATTn6ev02M3r7Ga5vqA==", + "dev": true, + "engines": { + "node": ">=0.4.x" + } + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/randomfill": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/randomfill/-/randomfill-1.0.4.tgz", + "integrity": "sha512-87lcbR8+MhcWcUiQ+9e+Rwx8MyR2P7qnt15ynUlbm3TU/fjbgz4GsvfSUDTemtCCtVCqb4ZcEFlyPNTh9bBTLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "randombytes": "^2.0.5", + "safe-buffer": "^5.1.0" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ripemd160": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.3.tgz", + "integrity": "sha512-5Di9UC0+8h1L6ZD2d7awM7E/T4uA1fJRlx6zk/NvdCCVEoAnFqvHmCuNeIKoCeIixBX/q8uM+6ycDvF8woqosA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hash-base": "^3.1.2", + "inherits": "^2.0.4" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/ripemd160/node_modules/hash-base": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.1.2.tgz", + "integrity": "sha512-Bb33KbowVTIj5s7Ked1OsqHUeCpz//tPwR+E2zJgJKo9Z5XolZ9b6bdUgjmYlwnWhoOQKoTd1TYToZGn5mAYOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.4", + "readable-stream": "^2.3.8", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/ripemd160/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/ripemd160/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/ripemd160/node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/ripemd160/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/ripemd160/node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "dev": true, + "license": "MIT" + }, + "node_modules/sha.js": { + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz", + "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==", + "dev": true, + "license": "(MIT AND BSD-3-Clause)", + "dependencies": { + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.0" + }, + "bin": { + "sha.js": "bin.js" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stream-browserify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-3.0.0.tgz", + "integrity": "sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "~2.0.4", + "readable-stream": "^3.5.0" + } + }, + "node_modules/stream-http": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/stream-http/-/stream-http-3.2.0.tgz", + "integrity": "sha512-Oq1bLqisTyK3TSCXpPbT4sdeYNdmyZJv1LxpEm2vu1ZhK89kSE5YXwZc3cWk0MagGaKriBh9mCFbVGtO+vY29A==", + "dev": true, + "license": "MIT", + "dependencies": { + "builtin-status-codes": "^3.0.0", + "inherits": "^2.0.4", + "readable-stream": "^3.6.0", + "xtend": "^4.0.2" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/timers-browserify": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/timers-browserify/-/timers-browserify-2.0.12.tgz", + "integrity": "sha512-9phl76Cqm6FhSX9Xe1ZUAMLtm1BLkKj2Qd5ApyWkXzsMRaA7dgr81kf4wJmQf/hAvg8EEyJxDo3du/0KlhPiKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "setimmediate": "^1.0.4" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/to-buffer": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", + "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "isarray": "^2.0.5", + "safe-buffer": "^5.2.1", + "typed-array-buffer": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/tty-browserify": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.1.tgz", + "integrity": "sha512-C3TaO7K81YvjCgQH9Q1S3R3P3BtN3RIM8n+OvX4il1K1zgE8ZhI0op7kClgkxtutIE8hQrcrHBXvIheqKUUCxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typescript": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", + "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/url": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/url/-/url-0.11.4.tgz", + "integrity": "sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^1.4.1", + "qs": "^6.12.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/util": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", + "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "is-arguments": "^1.0.4", + "is-generator-function": "^1.0.7", + "is-typed-array": "^1.1.3", + "which-typed-array": "^1.1.2" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/valibot": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.3.1.tgz", + "integrity": "sha512-sfdRir/QFM0JaF22hqTroPc5xy4DimuGQVKFrzF1YfGwaS1nJot3Y8VqMdLO2Lg27fMzat2yD3pY5PbAYO39Gg==", + "license": "MIT", + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vite": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-plugin-node-polyfills": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/vite-plugin-node-polyfills/-/vite-plugin-node-polyfills-0.24.0.tgz", + "integrity": "sha512-GA9QKLH+vIM8NPaGA+o2t8PDfFUl32J8rUp1zQfMKVJQiNkOX4unE51tR6ppl6iKw5yOrDAdSH7r/UIFLCVhLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/plugin-inject": "^5.0.5", + "node-stdlib-browser": "^1.2.0" + }, + "funding": { + "url": "https://github.com/sponsors/davidmyersdev" + }, + "peerDependencies": { + "vite": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/vm-browserify": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-1.1.2.tgz", + "integrity": "sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/which-typed-array": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/bindings/wasm/audit_trail_wasm/cypress/app/package.json b/bindings/wasm/audit_trail_wasm/cypress/app/package.json new file mode 100644 index 00000000..71ed8f81 --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/cypress/app/package.json @@ -0,0 +1,20 @@ +{ + "name": "vite-project", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "devDependencies": { + "typescript": "~5.7.2", + "vite": "^6.2.0", + "vite-plugin-node-polyfills": "^0.24.0" + }, + "dependencies": { + "@iota/iota-sdk": "^1.0.0", + "@iota/audit-trail": "file:../.." + } +} \ No newline at end of file diff --git a/bindings/wasm/audit_trail_wasm/cypress/app/src/audit_trail.ts b/bindings/wasm/audit_trail_wasm/cypress/app/src/audit_trail.ts new file mode 100644 index 00000000..eb5544b9 --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/cypress/app/src/audit_trail.ts @@ -0,0 +1,18 @@ +import url from "@iota/audit-trail/web/audit_trail_wasm_bg.wasm?url"; + +import { init } from "@iota/audit-trail/web"; +import { main } from "../../../examples/dist/web/web-main"; + +export const runTest = async (example: string) => { + try { + await main(example); + console.log("success"); + } catch (error) { + throw error; + } +}; + +init(url) + .then(() => { + console.log("init"); + }); \ No newline at end of file diff --git a/bindings/wasm/audit_trail_wasm/cypress/app/src/main.ts b/bindings/wasm/audit_trail_wasm/cypress/app/src/main.ts new file mode 100644 index 00000000..c8b81570 --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/cypress/app/src/main.ts @@ -0,0 +1,3 @@ +import { runTest } from "./audit_trail"; + +globalThis.runTest = runTest; \ No newline at end of file diff --git a/bindings/wasm/audit_trail_wasm/cypress/app/src/vite-env.d.ts b/bindings/wasm/audit_trail_wasm/cypress/app/src/vite-env.d.ts new file mode 100644 index 00000000..151aa685 --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/cypress/app/src/vite-env.d.ts @@ -0,0 +1 @@ +/// \ No newline at end of file diff --git a/bindings/wasm/audit_trail_wasm/cypress/app/tsconfig.json b/bindings/wasm/audit_trail_wasm/cypress/app/tsconfig.json new file mode 100644 index 00000000..9469f855 --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/cypress/app/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "useDefineForClassFields": true, + "module": "ESNext", + "lib": [ + "ES2020", + "DOM", + "DOM.Iterable" + ], + "skipLibCheck": true, + "allowJs": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": [ + "src" + ] +} \ No newline at end of file diff --git a/bindings/wasm/audit_trail_wasm/cypress/app/vite.config.js b/bindings/wasm/audit_trail_wasm/cypress/app/vite.config.js new file mode 100644 index 00000000..d7721504 --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/cypress/app/vite.config.js @@ -0,0 +1,38 @@ +import { defineConfig } from "vite"; +import { nodePolyfills } from "vite-plugin-node-polyfills"; +export default defineConfig(({ command, mode }) => { + // variables will be set during build time + const EXPOSED_ENVS = [ + "IOTA_AUDIT_TRAIL_PKG_ID", + "IOTA_TF_COMPONENTS_PKG_ID", + "NETWORK_NAME_FAUCET", + "NETWORK_URL", + ]; + + return { + plugins: [ + nodePolyfills({ + include: ["assert"], + }), + ], + define: EXPOSED_ENVS.reduce((prev, env_var) => { + const var_value = globalThis?.process?.env?.[env_var]; + if (var_value) { + console.log("exposing", env_var, var_value); + prev[`process.env.${env_var}`] = JSON.stringify(var_value); + } + return prev; + }, {}), + server: { + // open on default port or fail to make CI consistent + strictPort: true, + }, + build: { + rollupOptions: { + output: { + interop: "auto", + }, + }, + }, + }; +}); \ No newline at end of file diff --git a/bindings/wasm/audit_trail_wasm/cypress/e2e/tests.cy.js b/bindings/wasm/audit_trail_wasm/cypress/e2e/tests.cy.js new file mode 100644 index 00000000..e93d2d3c --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/cypress/e2e/tests.cy.js @@ -0,0 +1,35 @@ +const { _ } = Cypress; + +describe( + "Test Examples", + () => { + const examples = [ + "01_create_audit_trail", + "02_add_and_read_records", + "03_update_metadata", + "04_configure_locking", + "05_manage_access", + "06_delete_records", + "07_access_read_only_methods", + "08_delete_audit_trail", + "09_tagged_records", + "10_capability_constraints", + "11_manage_record_tags", + "01_customs_clearance", + "02_clinical_trial", + ]; + + _.each(examples, (example) => { + it(example, () => { + cy.visit("/", { + onBeforeLoad(win) { + cy.stub(win.console, "log").as("consoleLog"); + }, + }); + cy.get("@consoleLog").should("be.calledWith", "init"); + cy.window().then(win => win.runTest(example)); + cy.get("@consoleLog").should("be.calledWith", "success"); + }); + }); + }, +); \ No newline at end of file diff --git a/bindings/wasm/audit_trail_wasm/examples/README.md b/bindings/wasm/audit_trail_wasm/examples/README.md index e6ffeb6d..95a1d57a 100644 --- a/bindings/wasm/audit_trail_wasm/examples/README.md +++ b/bindings/wasm/audit_trail_wasm/examples/README.md @@ -1,11 +1,6 @@ # IOTA Audit Trail WASM Examples -The examples in this folder demonstrate the Core MVP flow of the `@iota/audit-trail` package: - -- create a trail -- fetch a trail -- add and page records -- delete records in batch +The examples in this folder demonstrate how to use the `@iota/audit-trail` package. ## Environment @@ -33,12 +28,35 @@ Run an example: IOTA_AUDIT_TRAIL_PKG_ID= \ IOTA_TF_COMPONENTS_PKG_ID= \ NETWORK_URL=http://127.0.0.1:9000 \ -npm run example:node -- 01_create_trail +npm run example:node -- 01_create_audit_trail ``` Available examples: -- `01_create_trail` -- `02_fetch_trail` -- `03_add_and_list_records` -- `04_delete_records_batch` +### Core + +| Name | Description | +| ------------------------------ | ------------------------------------------------------------------------------------------------- | +| `01_create_audit_trail` | Creates an audit trail, defines a RecordAdmin role, and issues a capability for it | +| `02_add_and_read_records` | Adds follow-up records, reads them individually and through paginated reads | +| `03_update_metadata` | Updates and clears mutable metadata while preserving immutable metadata via a MetadataAdmin role | +| `04_configure_locking` | Configures write and delete locks, demonstrates that locks block record creation | +| `05_manage_access` | Creates and updates a role, then demonstrates constrained capability issuance, revoke and destroy flows, denylist cleanup, and final role removal | +| `06_delete_records` | Deletes individual records and batch-deletes remaining records | +| `07_access_read_only_methods` | Reads trail metadata, record counts, pagination, and lock status | +| `08_delete_audit_trail` | Shows that non-empty trails cannot be deleted, batch-deletes records, then deletes the trail | + +### Advanced + +| Name | Description | +| ------------------------------ | --------------------------------------------------------------------------------------------- | +| `09_tagged_records` | Uses role tags and address-bound capabilities to restrict who may add tagged records | +| `10_capability_constraints` | Shows address-bound capability use and how revocation immediately blocks future writes | +| `11_manage_record_tags` | Delegates tag management, adds/removes tags, shows that in-use tags cannot be removed | + +### 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 | diff --git a/bindings/wasm/audit_trail_wasm/examples/src/01_create_audit_trail.ts b/bindings/wasm/audit_trail_wasm/examples/src/01_create_audit_trail.ts new file mode 100644 index 00000000..745f0456 --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/examples/src/01_create_audit_trail.ts @@ -0,0 +1,44 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +import { CapabilityIssueOptions, PermissionSet } from "@iota/audit-trail/node"; +import { strict as assert } from "assert"; +import { createTrailWithSeedRecord, getFundedClient, TEST_GAS_BUDGET } from "./util"; + +/** + * Demonstrates how to: + * 1. Create an audit trail with immutable metadata, updatable metadata, and a seed record. + * 2. Inspect the built-in Admin role. + * 3. Define a RecordAdmin role and issue a capability for it. + */ +export async function createAuditTrail(): Promise { + console.log("Creating an audit trail"); + + const client = await getFundedClient(); + const { output: trail, response } = await createTrailWithSeedRecord(client); + + console.log(`Created trail ${trail.id} with transaction ${response.digest}`); + console.log("Immutable metadata:", trail.immutableMetadata); + console.log("Updatable metadata:", trail.updatableMetadata); + console.log("Locking config:", trail.lockingConfig); + + assert.equal(trail.sequenceNumber, 1n); + assert.ok(trail.immutableMetadata); + assert.equal(trail.immutableMetadata?.name, "Example Audit Trail"); + + // Define a RecordAdmin role and issue a capability + const role = client.trail(trail.id).access().forRole("RecordAdmin"); + await role + .create(PermissionSet.recordAdminPermissions()) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + await role + .issueCapability(new CapabilityIssueOptions(client.senderAddress())) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + + const onChain = await client.trail(trail.id).get(); + const roleNames = onChain.roles.roles.map((r) => r.name); + console.log("Roles:", roleNames); + assert.ok(roleNames.includes("RecordAdmin")); +} \ No newline at end of file diff --git a/bindings/wasm/audit_trail_wasm/examples/src/01_create_trail.ts b/bindings/wasm/audit_trail_wasm/examples/src/01_create_trail.ts deleted file mode 100644 index 21405e34..00000000 --- a/bindings/wasm/audit_trail_wasm/examples/src/01_create_trail.ts +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright 2026 IOTA Stiftung -// SPDX-License-Identifier: Apache-2.0 - -import { strict as assert } from "assert"; -import { createTrailWithSeedRecord, getFundedClient } from "./util"; - -export async function createTrail(): Promise { - console.log("Creating an audit trail"); - - const client = await getFundedClient(); - const { output: trail, response } = await createTrailWithSeedRecord(client); - - console.log(`Created trail ${trail.id} with transaction ${response.digest}`); - console.log("Immutable metadata:", trail.immutableMetadata); - console.log("Updatable metadata:", trail.updatableMetadata); - console.log("Locking config:", trail.lockingConfig); - - assert.equal(trail.sequenceNumber, 1n); - assert.ok(trail.immutableMetadata); - assert.equal(trail.immutableMetadata?.name, "Example Audit Trail"); -} diff --git a/bindings/wasm/audit_trail_wasm/examples/src/03_add_and_list_records.ts b/bindings/wasm/audit_trail_wasm/examples/src/02_add_and_read_records.ts similarity index 66% rename from bindings/wasm/audit_trail_wasm/examples/src/03_add_and_list_records.ts rename to bindings/wasm/audit_trail_wasm/examples/src/02_add_and_read_records.ts index e516d26c..73222125 100644 --- a/bindings/wasm/audit_trail_wasm/examples/src/03_add_and_list_records.ts +++ b/bindings/wasm/audit_trail_wasm/examples/src/02_add_and_read_records.ts @@ -5,7 +5,13 @@ import { Data } from "@iota/audit-trail/node"; import { strict as assert } from "assert"; import { createTrailWithSeedRecord, getFundedClient, grantSelfRecordPermissions, TEST_GAS_BUDGET } from "./util"; -export async function addAndListRecords(): Promise { +/** + * Demonstrates how to: + * 1. Add follow-up records to a trail. + * 2. Read them back individually by sequence number. + * 3. Paginate through records. + */ +export async function addAndReadRecords(): Promise { console.log("Adding records and reading them back with pagination"); const client = await getFundedClient(); @@ -13,7 +19,8 @@ export async function addAndListRecords(): Promise { await grantSelfRecordPermissions(client, trail.id); const records = client.trail(trail.id).records(); - const addedString = await records + // Add records + const addedSecond = await records .add(Data.fromString("record 2"), "second") .withGasBudget(TEST_GAS_BUDGET) .buildAndExecute(client); @@ -22,18 +29,22 @@ export async function addAndListRecords(): Promise { .withGasBudget(TEST_GAS_BUDGET) .buildAndExecute(client); - console.log("Added records:", addedString.output, addedThird.output); + console.log("Added records:", addedSecond.output, addedThird.output); - const allRecords = await records.list(); + // Read individual records + const initial = await records.get(0n); + const first = await records.get(addedSecond.output.sequenceNumber); + assert.equal(initial.data.toString(), "seed record"); + assert.equal(first.data.toString(), "record 2"); + + // Paginate const firstPage = await records.listPage(undefined, 2); const secondPage = await records.listPage(firstPage.nextCursor, 2); - console.log("All records:", allRecords); console.log("First page:", firstPage); console.log("Second page:", secondPage); - assert.equal(allRecords.length, 3); assert.equal(firstPage.records.length, 2); assert.equal(firstPage.hasNextPage, true); assert.equal(secondPage.records.length, 1); -} +} \ No newline at end of file diff --git a/bindings/wasm/audit_trail_wasm/examples/src/02_fetch_trail.ts b/bindings/wasm/audit_trail_wasm/examples/src/02_fetch_trail.ts deleted file mode 100644 index 34479de5..00000000 --- a/bindings/wasm/audit_trail_wasm/examples/src/02_fetch_trail.ts +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright 2026 IOTA Stiftung -// SPDX-License-Identifier: Apache-2.0 - -import { strict as assert } from "assert"; -import { createTrailWithSeedRecord, getFundedClient } from "./util"; - -export async function fetchTrail(): Promise { - console.log("Fetching an existing audit trail"); - - const client = await getFundedClient(); - const { output: createdTrail } = await createTrailWithSeedRecord(client); - - const fetchedTrail = await client.readOnly().trail(createdTrail.id).get(); - - console.log("Fetched trail:", fetchedTrail); - assert.equal(fetchedTrail.id, createdTrail.id); - assert.equal(fetchedTrail.sequenceNumber, createdTrail.sequenceNumber); - assert.equal(fetchedTrail.immutableMetadata?.name, createdTrail.immutableMetadata?.name); -} diff --git a/bindings/wasm/audit_trail_wasm/examples/src/03_update_metadata.ts b/bindings/wasm/audit_trail_wasm/examples/src/03_update_metadata.ts new file mode 100644 index 00000000..92977873 --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/examples/src/03_update_metadata.ts @@ -0,0 +1,68 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +import { CapabilityIssueOptions, PermissionSet } from "@iota/audit-trail/node"; +import { strict as assert } from "assert"; +import { createTrailWithSeedRecord, getFundedClient, TEST_GAS_BUDGET } from "./util"; + +/** + * Demonstrates how to: + * 1. Create a trail with immutable and updatable metadata. + * 2. Delegate metadata updates through a dedicated MetadataAdmin role. + * 3. Change and clear the trail's updatable metadata. + * 4. Verify that immutable metadata never changes. + */ +export async function updateMetadata(): Promise { + console.log("=== Audit Trail: Update Metadata ===\n"); + + const client = await getFundedClient(); + const { output: trail } = await client + .createTrail() + .withTrailMetadata("Shipment Processing", "Tracks the lifecycle of a warehouse shipment") + .withUpdatableMetadata("Status: Draft") + .withInitialRecordString("Shipment created", "event:created") + .finish() + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + + const trailId = trail.id; + const trailHandle = client.trail(trailId); + + // Delegate metadata updates to a MetadataAdmin role + const role = trailHandle.access().forRole("MetadataAdmin"); + await role.create(PermissionSet.metadataAdminPermissions()).withGasBudget(TEST_GAS_BUDGET).buildAndExecute(client); + await role + .issueCapability(new CapabilityIssueOptions(client.senderAddress())) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + + const before = await trailHandle.get(); + console.log("Before update:"); + console.log(" immutable =", before.immutableMetadata); + console.log(" updatable =", before.updatableMetadata, "\n"); + + // Update the mutable metadata + await trailHandle + .updateMetadata("Status: In Review") + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + + const afterUpdate = await trailHandle.get(); + console.log("After update:"); + console.log(" immutable =", afterUpdate.immutableMetadata); + console.log(" updatable =", afterUpdate.updatableMetadata, "\n"); + + assert.equal(afterUpdate.immutableMetadata?.name, "Shipment Processing"); + assert.equal(afterUpdate.updatableMetadata, "Status: In Review"); + + // Clear the mutable metadata + await trailHandle.updateMetadata(undefined).withGasBudget(TEST_GAS_BUDGET).buildAndExecute(client); + + const afterClear = await trailHandle.get(); + console.log("After clear:"); + console.log(" immutable =", afterClear.immutableMetadata); + console.log(" updatable =", afterClear.updatableMetadata); + + assert.equal(afterClear.immutableMetadata?.name, "Shipment Processing"); + assert.equal(afterClear.updatableMetadata, undefined); +} \ No newline at end of file diff --git a/bindings/wasm/audit_trail_wasm/examples/src/04_configure_locking.ts b/bindings/wasm/audit_trail_wasm/examples/src/04_configure_locking.ts new file mode 100644 index 00000000..b555049b --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/examples/src/04_configure_locking.ts @@ -0,0 +1,99 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +import { CapabilityIssueOptions, Data, LockingConfig, LockingWindow, PermissionSet, TimeLock } from "@iota/audit-trail/node"; +import { strict as assert } from "assert"; +import { createTrailWithSeedRecord, getFundedClient, TEST_GAS_BUDGET } from "./util"; + +/** + * Demonstrates how to: + * 1. Delegate locking updates through a LockingAdmin role. + * 2. Freeze record creation with a write lock. + * 3. Restore writes and add a new record. + * 4. Update the delete-record window and delete-trail lock. + */ +export async function configureLocking(): Promise { + console.log("=== Audit Trail: Configure Locking ===\n"); + + const client = await getFundedClient(); + const { output: trail } = await createTrailWithSeedRecord(client); + const trailId = trail.id; + const trailHandle = client.trail(trailId); + + // Create LockingAdmin and RecordAdmin roles + const lockingRole = trailHandle.access().forRole("LockingAdmin"); + await lockingRole + .create(PermissionSet.lockingAdminPermissions()) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + await lockingRole + .issueCapability(new CapabilityIssueOptions(client.senderAddress())) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + + const recordRole = trailHandle.access().forRole("RecordAdmin"); + await recordRole + .create(PermissionSet.recordAdminPermissions()) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + await recordRole + .issueCapability(new CapabilityIssueOptions(client.senderAddress())) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + + // Freeze writes + await trailHandle + .locking() + .updateWriteLock(TimeLock.withInfinite()) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + + const locked = await trailHandle.get(); + console.log("Write lock after update:", locked.lockingConfig.writeLock, "\n"); + assert.equal(locked.lockingConfig.writeLock.type, TimeLock.withInfinite().type); + + // Attempt to add a record while locked — should fail + const blockedAdd = await trailHandle + .records() + .add(Data.fromString("This write should fail"), "blocked") + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client) + .catch(() => null); + assert.equal(blockedAdd, null, "write lock should block adding records"); + + // Lift the write lock + await trailHandle + .locking() + .updateWriteLock(TimeLock.withNone()) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + + const added = await trailHandle + .records() + .add(Data.fromString("Write lock lifted"), "event:resumed") + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + console.log("Added record", added.output.sequenceNumber, "after clearing the write lock.\n"); + + // Configure deletion window and trail lock + await trailHandle + .locking() + .updateDeleteRecordWindow(LockingWindow.withCountBased(BigInt(2))) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + await trailHandle + .locking() + .updateDeleteTrailLock(TimeLock.withInfinite()) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + + const finalState = await trailHandle.get(); + console.log("Final locking config:"); + console.log(" delete_record_window =", finalState.lockingConfig.deleteRecordWindow); + console.log(" delete_trail_lock =", finalState.lockingConfig.deleteTrailLock); + console.log(" write_lock =", finalState.lockingConfig.writeLock); + + assert.equal(finalState.lockingConfig.deleteRecordWindow.type, LockingWindow.withCountBased(BigInt(2)).type); + assert.equal(finalState.lockingConfig.deleteTrailLock.type, TimeLock.withInfinite().type); + assert.equal(finalState.lockingConfig.writeLock.type, TimeLock.withNone().type); +} diff --git a/bindings/wasm/audit_trail_wasm/examples/src/04_delete_records_batch.ts b/bindings/wasm/audit_trail_wasm/examples/src/04_delete_records_batch.ts deleted file mode 100644 index 29dc8147..00000000 --- a/bindings/wasm/audit_trail_wasm/examples/src/04_delete_records_batch.ts +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright 2026 IOTA Stiftung -// SPDX-License-Identifier: Apache-2.0 - -import { Data } from "@iota/audit-trail/node"; -import { strict as assert } from "assert"; -import { createTrailWithSeedRecord, getFundedClient, grantSelfRecordPermissions, TEST_GAS_BUDGET } from "./util"; - -export async function deleteRecordsBatch(): Promise { - console.log("Deleting records in batch"); - - const client = await getFundedClient(); - const { output: trail } = await createTrailWithSeedRecord(client); - await grantSelfRecordPermissions(client, trail.id); - const records = client.trail(trail.id).records(); - - await records.add(Data.fromString("record 2"), "second").withGasBudget(TEST_GAS_BUDGET).buildAndExecute(client); - await records.add(Data.fromString("record 3"), "third").withGasBudget(TEST_GAS_BUDGET).buildAndExecute(client); - - const before = await records.recordCount(); - const deleted = await records.deleteBatch(BigInt(2)).withGasBudget(TEST_GAS_BUDGET).buildAndExecute(client); - const after = await records.recordCount(); - - console.log(`Deleted ${deleted.output} records. Count before=${before}, after=${after}`); - - assert.equal(before, 3n); - assert.equal(deleted.output, 2n); - assert.equal(after, 1n); -} diff --git a/bindings/wasm/audit_trail_wasm/examples/src/05_manage_access.ts b/bindings/wasm/audit_trail_wasm/examples/src/05_manage_access.ts new file mode 100644 index 00000000..9ca58010 --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/examples/src/05_manage_access.ts @@ -0,0 +1,98 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +import { CapabilityIssueOptions, Permission, PermissionSet } from "@iota/audit-trail/node"; +import { strict as assert } from "assert"; +import { createTrailWithSeedRecord, getFundedClient, TEST_GAS_BUDGET } from "./util"; + +/** + * Demonstrates how to: + * 1. Create and update a custom role. + * 2. Issue a constrained capability for that role. + * 3. Revoke one capability and destroy another. + * 4. Remove the role after its capabilities are no longer needed. + */ +export async function manageAccess(): Promise { + console.log("=== Audit Trail: Manage Access ===\n"); + + const client = await getFundedClient(); + const { output: trail } = await createTrailWithSeedRecord(client); + const trailId = trail.id; + const trailHandle = client.trail(trailId); + const role = trailHandle.access().forRole("Operations"); + + // 1. Create the role + const createdRole = await role + .create(PermissionSet.recordAdminPermissions()) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + console.log("Created role:", createdRole.output.role, "\n"); + + // 2. Update the role permissions + const updatedPermissionValues = [ + Permission.AddRecord, + Permission.DeleteRecord, + Permission.DeleteAllRecords, + ]; + const updatedPermissions = new PermissionSet(updatedPermissionValues); + const updatedRole = await role + .updatePermissions(updatedPermissions) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + console.log("Updated role permissions:", updatedRole.output.permissions.permissions.map((p) => p.toString())); + + // 3. Issue a constrained capability (address-bound, time-limited) + const constrainedCap = await role + .issueCapability(new CapabilityIssueOptions(client.senderAddress(), undefined, BigInt(4_102_444_800_000))) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + console.log("\nIssued constrained capability:"); + console.log(" id =", constrainedCap.output.capabilityId); + console.log(" issued_to =", constrainedCap.output.issuedTo); + console.log(" valid_until =", constrainedCap.output.validUntil, "\n"); + + // Verify the on-chain role matches the updated permissions + const onChain = await trailHandle.get(); + const opsRole = onChain.roles.roles.find((r) => r.name === "Operations"); + assert.ok(opsRole, "Operations role must exist"); + const opsPermSet = new Set(opsRole.permissions.map((p) => p.toString())); + for (const perm of updatedPermissionValues) { + assert(opsPermSet.has(perm.toString()), `role should contain ${perm}`); + } + + // 4. Revoke the constrained capability + await trailHandle + .access() + .revokeCapability(constrainedCap.output.capabilityId, constrainedCap.output.validUntil) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + console.log("Revoked capability", constrainedCap.output.capabilityId, "\n"); + + // 5. Issue a disposable capability and destroy it + const disposableCap = await role + .issueCapability(new CapabilityIssueOptions(client.senderAddress())) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + await trailHandle + .access() + .destroyCapability(disposableCap.output.capabilityId) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + console.log("Destroyed capability", disposableCap.output.capabilityId, "\n"); + + // 6. Clean up the revoked-capability registry entry so the role can be removed. + await trailHandle + .access() + .cleanupRevokedCapabilities() + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + console.log("Cleaned up revoked capability registry entries.\n"); + + // 7. Delete the role + await role.delete().withGasBudget(TEST_GAS_BUDGET).buildAndExecute(client); + const afterDelete = await trailHandle.get(); + const opsRoleAfterDelete = afterDelete.roles.roles.find((r) => r.name === "Operations"); + assert.equal(opsRoleAfterDelete, undefined, "role should be removed from the trail"); + + console.log("Removed the custom role after its capability lifecycle completed."); +} diff --git a/bindings/wasm/audit_trail_wasm/examples/src/06_delete_records.ts b/bindings/wasm/audit_trail_wasm/examples/src/06_delete_records.ts new file mode 100644 index 00000000..550f3fd6 --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/examples/src/06_delete_records.ts @@ -0,0 +1,88 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +import { + CapabilityIssueOptions, + Data, + LockingConfig, + LockingWindow, + Permission, + PermissionSet, + TimeLock, +} from "@iota/audit-trail/node"; +import { strict as assert } from "assert"; +import { getFundedClient, TEST_GAS_BUDGET } from "./util"; + +/** + * Demonstrates how to: + * 1. Create records via a delegated RecordMaintenance role. + * 2. Delete a single record by sequence number. + * 3. Batch-delete remaining records. + */ +export async function deleteRecords(): Promise { + console.log("=== Audit Trail: Delete Records ===\n"); + + const client = await getFundedClient(); + const { output: trail } = await client + .createTrail() + .withTrailMetadata("Delete Records Example", "Trail configured to demonstrate record deletions") + .withUpdatableMetadata("Status: Active") + .withLockingConfig( + new LockingConfig(LockingWindow.withNone(), TimeLock.withNone(), TimeLock.withNone()), + ) + .withInitialRecordString("Seed record", "v0") + .finish() + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + const trailId = trail.id; + const trailHandle = client.trail(trailId); + + // Create a role with delete permissions + const role = trailHandle.access().forRole("RecordMaintenance"); + await role + .create(new PermissionSet([Permission.AddRecord, Permission.DeleteRecord, Permission.DeleteAllRecords])) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + await role + .issueCapability(new CapabilityIssueOptions(client.senderAddress())) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + + // Add records + const rec1 = await trailHandle + .records() + .add(Data.fromString("First record"), "v1") + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + const rec2 = await trailHandle + .records() + .add(Data.fromString("Second record"), "v2") + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + + console.log("Added records", rec1.output.sequenceNumber, "and", rec2.output.sequenceNumber); + + // Delete a single record + const deleted = await trailHandle + .records() + .delete(rec1.output.sequenceNumber) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + console.log("Deleted record", deleted.output.sequenceNumber); + + let count = await trailHandle.records().recordCount(); + console.log("Record count after single delete:", count); + assert.equal(count, 2n); // seed + rec2 + + // Batch-delete remaining + const batchDeleted = await trailHandle + .records() + .deleteBatch(BigInt(10)) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + console.log("Batch deleted", batchDeleted.output, "records"); + + count = await trailHandle.records().recordCount(); + assert.equal(count, 0n, "all records should be deleted after batch"); + console.log("Record count after batch delete:", count); +} diff --git a/bindings/wasm/audit_trail_wasm/examples/src/07_access_read_only_methods.ts b/bindings/wasm/audit_trail_wasm/examples/src/07_access_read_only_methods.ts new file mode 100644 index 00000000..d8626e93 --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/examples/src/07_access_read_only_methods.ts @@ -0,0 +1,76 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +import { CapabilityIssueOptions, Data, LockingConfig, LockingWindow, PermissionSet, TimeLock } from "@iota/audit-trail/node"; +import { strict as assert } from "assert"; +import { getFundedClient, TEST_GAS_BUDGET } from "./util"; + +/** + * Demonstrates how to: + * 1. Load the full on-chain trail object. + * 2. Inspect metadata, roles, and locking configuration. + * 3. Read records individually and through pagination. + * 4. Query the record-count and lock-status helpers. + */ +export async function accessReadOnlyMethods(): Promise { + console.log("=== Audit Trail: Read-Only Inspection ===\n"); + + const client = await getFundedClient(); + const { output: created } = await client + .createTrail() + .withTrailMetadata("Operations Trail", "Used to inspect read-only accessors") + .withUpdatableMetadata("Status: Active") + .withLockingConfig( + new LockingConfig(LockingWindow.withCountBased(BigInt(2)), TimeLock.withNone(), TimeLock.withNone()), + ) + .withInitialRecordString("Initial record", "event:created") + .finish() + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + + const trailId = created.id; + const trailHandle = client.trail(trailId); + + // Create RecordAdmin role + const role = trailHandle.access().forRole("RecordAdmin"); + await role.create(PermissionSet.recordAdminPermissions()).withGasBudget(TEST_GAS_BUDGET).buildAndExecute(client); + await role + .issueCapability(new CapabilityIssueOptions(client.senderAddress())) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + + // Add a follow-up record + await trailHandle + .records() + .add(Data.fromString("Follow-up record"), "event:updated") + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + + // Read the full on-chain trail + const onChain = await trailHandle.get(); + console.log("Trail summary:"); + console.log(" id =", onChain.id); + console.log(" creator =", onChain.creator); + console.log(" created_at =", onChain.createdAt); + console.log(" sequence_number =", onChain.sequenceNumber); + console.log(" immutable_metadata =", onChain.immutableMetadata); + console.log(" updatable_metadata =", onChain.updatableMetadata, "\n"); + + console.log("Roles:", onChain.roles.roles.map((r) => r.name)); + console.log("Locking config:", onChain.lockingConfig, "\n"); + + // Query helpers + const count = await trailHandle.records().recordCount(); + const initialRecord = await trailHandle.records().get(0n); + const firstPage = await trailHandle.records().listPage(undefined, 10); + const recordZeroLocked = await trailHandle.locking().isRecordLocked(0n); + + console.log("Record count:", count); + console.log("Record #0:", initialRecord); + console.log("First page size:", firstPage.records.length, "(has_next_page =", firstPage.hasNextPage, ")"); + console.log("Is record #0 locked?", recordZeroLocked); + + assert.equal(count, 2n); + assert.equal(initialRecord.data.toString(), "Initial record"); + assert.equal(firstPage.records.length, 2); +} \ No newline at end of file diff --git a/bindings/wasm/audit_trail_wasm/examples/src/08_delete_audit_trail.ts b/bindings/wasm/audit_trail_wasm/examples/src/08_delete_audit_trail.ts new file mode 100644 index 00000000..4c8a4c25 --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/examples/src/08_delete_audit_trail.ts @@ -0,0 +1,78 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +import { CapabilityIssueOptions, Data, Permission, PermissionSet } from "@iota/audit-trail/node"; +import { strict as assert } from "assert"; +import { getFundedClient, TEST_GAS_BUDGET } from "./util"; + +/** + * Demonstrates how to: + * 1. Show that a non-empty trail cannot be deleted. + * 2. Empty the trail with deleteBatch. + * 3. Delete the trail once its records are gone. + */ +export async function deleteAuditTrail(): Promise { + console.log("=== Audit Trail: Delete Trail ===\n"); + + const client = await getFundedClient(); + const { output: created } = await client + .createTrail() + .withInitialRecordString("Initial record", "event:created") + .finish() + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + + const trailId = created.id; + const trailHandle = client.trail(trailId); + + // Create a role with delete permissions + const role = trailHandle.access().forRole("MaintenanceAdmin"); + await role + .create(new PermissionSet([Permission.DeleteAllRecords, Permission.DeleteAuditTrail])) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + await role + .issueCapability(new CapabilityIssueOptions(client.senderAddress())) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + + // 1. Attempting to delete a non-empty trail should fail + let deleteWhileNonEmptySucceeded = false; + try { + await trailHandle.deleteAuditTrail().withGasBudget(TEST_GAS_BUDGET).buildAndExecute(client); + deleteWhileNonEmptySucceeded = true; + } catch { + // Expected + } + assert.equal(deleteWhileNonEmptySucceeded, false, "a trail must be empty before deletion"); + console.log("Deleting the non-empty trail failed as expected.\n"); + + // 2. Batch-delete all records + const deletedRecords = await trailHandle + .records() + .deleteBatch(BigInt(10)) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + console.log("Deleted", deletedRecords.output, "record(s) before trail removal.\n"); + + const count = await trailHandle.records().recordCount(); + assert.equal(count, 0n, "trail should have no records after batch delete"); + + // 3. Delete the now-empty trail + const deletedTrail = await trailHandle + .deleteAuditTrail() + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + console.log("Trail deleted:"); + console.log(" trail_id =", deletedTrail.output.trailId); + console.log(" timestamp =", deletedTrail.output.timestamp); + + let getAfterDeleteSucceeded = false; + try { + await trailHandle.get(); + getAfterDeleteSucceeded = true; + } catch { + // Expected + } + assert.equal(getAfterDeleteSucceeded, false, "deleted trail should no longer be readable"); +} \ No newline at end of file diff --git a/bindings/wasm/audit_trail_wasm/examples/src/advanced/09_tagged_records.ts b/bindings/wasm/audit_trail_wasm/examples/src/advanced/09_tagged_records.ts new file mode 100644 index 00000000..f11ec4d3 --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/examples/src/advanced/09_tagged_records.ts @@ -0,0 +1,71 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +import { CapabilityIssueOptions, Data, Permission, PermissionSet, RoleTags } from "@iota/audit-trail/node"; +import { strict as assert } from "assert"; +import { getFundedClient, TEST_GAS_BUDGET } from "../util"; + +/** + * Demonstrates how to: + * 1. Create a trail with a predefined tag registry. + * 2. Define a role that is restricted to one record tag. + * 3. Issue a capability bound to a specific wallet address. + * 4. Show that the holder can add only records matching the allowed tag. + */ +export async function taggedRecords(): Promise { + console.log("=== Audit Trail Advanced: Tagged Records ===\n"); + + const admin = await getFundedClient(); + const financeWriter = await getFundedClient(); + + const { output: created } = await admin + .createTrail() + .withRecordTags(["finance", "legal"]) + .withInitialRecordString("Trail created", "event:created") + .finish() + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(admin); + + const trailId = created.id; + + // Create a role restricted to the "finance" tag + const role = admin.trail(trailId).access().forRole("FinanceWriter"); + await role + .create(new PermissionSet([Permission.AddRecord]), new RoleTags(["finance"])) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(admin); + + const issued = await role + .issueCapability(new CapabilityIssueOptions(financeWriter.senderAddress())) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(admin); + + console.log("Issued FinanceWriter capability", issued.output.capabilityId, "to", financeWriter.senderAddress(), "\n"); + + const financeRecords = financeWriter.trail(trailId).records(); + + // Add a record with the allowed tag + const added = await financeRecords + .add(Data.fromString("Invoice approved"), "department:finance", "finance") + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(financeWriter); + + console.log("Added tagged record at sequence number", added.output.sequenceNumber, 'with tag "finance".\n'); + + // Attempt to add a record with a different tag — should fail + let wrongTagSucceeded = false; + try { + await financeRecords + .add(Data.fromString("Legal review completed"), "department:legal", "legal") + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(financeWriter); + wrongTagSucceeded = true; + } catch { + // Expected + } + assert.equal(wrongTagSucceeded, false, "a finance-scoped role must not add a legal-tagged record"); + + const financeRecord = await financeRecords.get(added.output.sequenceNumber); + console.log("Stored tagged record:", financeRecord); + assert.equal(financeRecord.tag, "finance"); +} diff --git a/bindings/wasm/audit_trail_wasm/examples/src/advanced/10_capability_constraints.ts b/bindings/wasm/audit_trail_wasm/examples/src/advanced/10_capability_constraints.ts new file mode 100644 index 00000000..a9ce38ed --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/examples/src/advanced/10_capability_constraints.ts @@ -0,0 +1,93 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +import { CapabilityIssueOptions, Data, PermissionSet } from "@iota/audit-trail/node"; +import { strict as assert } from "assert"; +import { createTrailWithSeedRecord, getFundedClient, TEST_GAS_BUDGET } from "../util"; + +/** + * Demonstrates how to: + * 1. Bind a capability to a specific wallet address. + * 2. Show that a different wallet cannot use it. + * 3. Revoke the capability and confirm the bound holder can no longer use it. + */ +export async function capabilityConstraints(): Promise { + console.log("=== Audit Trail Advanced: Capability Constraints ===\n"); + + const admin = await getFundedClient(); + const intendedWriter = await getFundedClient(); + const wrongWriter = await getFundedClient(); + + const { output: created } = await createTrailWithSeedRecord(admin); + const trailId = created.id; + + // Create a RecordAdmin role + await admin + .trail(trailId) + .access() + .forRole("RecordAdmin") + .create(PermissionSet.recordAdminPermissions()) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(admin); + + // Issue a capability bound to the intended writer's address + const issued = await admin + .trail(trailId) + .access() + .forRole("RecordAdmin") + .issueCapability(new CapabilityIssueOptions(intendedWriter.senderAddress())) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(admin); + + console.log("Issued capability", issued.output.capabilityId, "to", intendedWriter.senderAddress(), "\n"); + + // The wrong wallet should not be able to add a record + let wrongWriterSucceeded = false; + try { + await wrongWriter + .trail(trailId) + .records() + .add(Data.fromString("Wrong writer"), undefined, undefined) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(wrongWriter); + wrongWriterSucceeded = true; + } catch { + // Expected + } + assert.equal(wrongWriterSucceeded, false, "a capability bound to another address must not be usable"); + + // The intended writer CAN add a record + const added = await intendedWriter + .trail(trailId) + .records() + .add(Data.fromString("Authorized writer"), undefined, undefined) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(intendedWriter); + + console.log("Bound holder added record", added.output.sequenceNumber, "successfully.\n"); + + // Revoke the capability + await admin + .trail(trailId) + .access() + .revokeCapability(issued.output.capabilityId, issued.output.validUntil) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(admin); + + // The intended writer should no longer be able to add a record + let revokedSucceeded = false; + try { + await intendedWriter + .trail(trailId) + .records() + .add(Data.fromString("Should fail after revoke"), undefined, undefined) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(intendedWriter); + revokedSucceeded = true; + } catch { + // Expected + } + assert.equal(revokedSucceeded, false, "revoked capabilities must no longer authorize record writes"); + + console.log("Revoked capability", issued.output.capabilityId, "and verified it can no longer be used."); +} \ No newline at end of file diff --git a/bindings/wasm/audit_trail_wasm/examples/src/advanced/11_manage_record_tags.ts b/bindings/wasm/audit_trail_wasm/examples/src/advanced/11_manage_record_tags.ts new file mode 100644 index 00000000..583d6a98 --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/examples/src/advanced/11_manage_record_tags.ts @@ -0,0 +1,89 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +import { CapabilityIssueOptions, Data, PermissionSet, RoleTags } from "@iota/audit-trail/node"; +import { strict as assert } from "assert"; +import { getFundedClient, TEST_GAS_BUDGET } from "../util"; + +/** + * Demonstrates how to: + * 1. Delegate record-tag registry management to a TagAdmin role. + * 2. Add and remove tags from the trail registry. + * 3. Show that tags still in use by roles or records cannot be removed. + */ +export async function manageRecordTags(): Promise { + console.log("=== Audit Trail Advanced: Manage Record Tags ===\n"); + + const client = await getFundedClient(); + + const { output: created } = await client + .createTrail() + .withRecordTags(["finance"]) + .withInitialRecordString("Trail created") + .finish() + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + + const trailId = created.id; + const trailHandle = client.trail(trailId); + + // Delegate tag management to a TagAdmin role + const tagAdminRole = trailHandle.access().forRole("TagAdmin"); + await tagAdminRole + .create(PermissionSet.tagAdminPermissions()) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + await tagAdminRole + .issueCapability(new CapabilityIssueOptions(client.senderAddress())) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + + // Add a new tag + await trailHandle.tags().add("legal").withGasBudget(TEST_GAS_BUDGET).buildAndExecute(client); + + let onChain = await trailHandle.get(); + console.log('Registry after adding "legal":', onChain.tags.map((t) => t.tag), "\n"); + assert.ok(onChain.tags.some((t) => t.tag === "finance")); + assert.ok(onChain.tags.some((t) => t.tag === "legal")); + + // Create a role scoped to "finance" tag + await trailHandle + .access() + .forRole("FinanceWriter") + .create(PermissionSet.recordAdminPermissions(), new RoleTags(["finance"])) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + await trailHandle + .access() + .forRole("FinanceWriter") + .issueCapability(new CapabilityIssueOptions(client.senderAddress())) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + + // Add a record using the "finance" tag + await trailHandle + .records() + .add(Data.fromString("Tagged finance entry"), undefined, "finance") + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + + // Attempt to remove "finance" tag — should fail because it's in use + let removeFinanceSucceeded = false; + try { + await trailHandle.tags().remove("finance").withGasBudget(TEST_GAS_BUDGET).buildAndExecute(client); + removeFinanceSucceeded = true; + } catch { + // Expected + } + assert.equal(removeFinanceSucceeded, false, "a tag referenced by a role or record must not be removable"); + + // Remove "legal" tag — should succeed because nothing uses it + await trailHandle.tags().remove("legal").withGasBudget(TEST_GAS_BUDGET).buildAndExecute(client); + + onChain = await trailHandle.get(); + console.log('Registry after removing "legal":', onChain.tags.map((t) => t.tag), "\n"); + assert.ok(onChain.tags.some((t) => t.tag === "finance"), "finance tag should still exist"); + assert.ok(!onChain.tags.some((t) => t.tag === "legal"), "legal tag should be removed"); + + console.log("Tag management completed successfully."); +} \ No newline at end of file diff --git a/bindings/wasm/audit_trail_wasm/examples/src/main.ts b/bindings/wasm/audit_trail_wasm/examples/src/main.ts index 79e17271..955b0ef9 100644 --- a/bindings/wasm/audit_trail_wasm/examples/src/main.ts +++ b/bindings/wasm/audit_trail_wasm/examples/src/main.ts @@ -1,26 +1,53 @@ // Copyright 2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -import { createTrail } from "./01_create_trail"; -import { fetchTrail } from "./02_fetch_trail"; -import { addAndListRecords } from "./03_add_and_list_records"; -import { deleteRecordsBatch } from "./04_delete_records_batch"; +import { createAuditTrail } from "./01_create_audit_trail"; +import { addAndReadRecords } from "./02_add_and_read_records"; +import { updateMetadata } from "./03_update_metadata"; +import { configureLocking } from "./04_configure_locking"; +import { manageAccess } from "./05_manage_access"; +import { deleteRecords } from "./06_delete_records"; +import { accessReadOnlyMethods } from "./07_access_read_only_methods"; +import { deleteAuditTrail } from "./08_delete_audit_trail"; +import { taggedRecords } from "./advanced/09_tagged_records"; +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"; export async function main(example?: string) { const argument = example ?? process.argv?.[2]?.toLowerCase(); if (!argument) { - throw new Error("Please specify an example name, e.g. '01_create_trail'"); + throw new Error("Please specify an example name, e.g. '01_create_audit_trail'"); } switch (argument) { - case "01_create_trail": - return createTrail(); - case "02_fetch_trail": - return fetchTrail(); - case "03_add_and_list_records": - return addAndListRecords(); - case "04_delete_records_batch": - return deleteRecordsBatch(); + case "01_create_audit_trail": + return createAuditTrail(); + case "02_add_and_read_records": + return addAndReadRecords(); + case "03_update_metadata": + return updateMetadata(); + case "04_configure_locking": + return configureLocking(); + case "05_manage_access": + return manageAccess(); + case "06_delete_records": + return deleteRecords(); + case "07_access_read_only_methods": + return accessReadOnlyMethods(); + case "08_delete_audit_trail": + return deleteAuditTrail(); + case "09_tagged_records": + return taggedRecords(); + case "10_capability_constraints": + return capabilityConstraints(); + case "11_manage_record_tags": + return manageRecordTags(); + case "01_customs_clearance": + return customsClearance(); + case "02_clinical_trial": + return clinicalTrial(); default: throw new Error(`Unknown example name: '${argument}'`); } @@ -28,4 +55,4 @@ export async function main(example?: string) { main().catch((error) => { console.error("Example error:", error); -}); +}); \ No newline at end of file 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 new file mode 100644 index 00000000..959940f0 --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/examples/src/real-world/01_customs_clearance.ts @@ -0,0 +1,252 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +import { + 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"; + +/** + * # Customs Clearance Example + * + * Models a customs-clearance process for a single shipment. + * + * - immutable_metadata: shipment and declaration identity + * - updatable_metadata: current customs-processing status + * - record tags: documents, export, import, inspection + * - roles and capabilities: each operational role writes only the events it owns + * - locking: writes are frozen once the shipment is fully cleared + */ +export async function customsClearance(): Promise { + console.log("=== Customs Clearance ===\n"); + + const client = await getFundedClient(); + + // 1. Create the trail + console.log("Creating a customs-clearance trail..."); + + const { output: created } = await client + .createTrail() + .withRecordTags(["documents", "export", "import", "inspection"]) + .withTrailMetadata( + "Shipment SHP-2026-CLEAR-001", + "Route: Hamburg, Germany -> Nairobi, Kenya | Declaration: DEC-2026-44017", + ) + .withUpdatableMetadata("Status: Documents Pending") + .withLockingConfig( + new LockingConfig(LockingWindow.withCountBased(BigInt(2)), TimeLock.withNone(), TimeLock.withNone()), + ) + .withInitialRecordString("Customs clearance case opened for inbound shipment", "event:case_opened", "documents") + .finish() + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + + const trailId = created.id; + + // 2. Create tag-scoped roles + await issueTaggedRecordRole(client, trailId, "DocsOperator", "documents"); + await issueTaggedRecordRole(client, trailId, "ExportBroker", "export"); + await issueTaggedRecordRole(client, trailId, "ImportBroker", "import"); + + // Supervisor can update metadata + await client + .trail(trailId) + .access() + .forRole("Supervisor") + .create(PermissionSet.metadataAdminPermissions()) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + await client + .trail(trailId) + .access() + .forRole("Supervisor") + .issueCapability(new CapabilityIssueOptions(client.senderAddress())) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + + // LockingAdmin can manage locking + await client + .trail(trailId) + .access() + .forRole("LockingAdmin") + .create(PermissionSet.lockingAdminPermissions()) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + await client + .trail(trailId) + .access() + .forRole("LockingAdmin") + .issueCapability(new CapabilityIssueOptions(client.senderAddress())) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + + // 3. Upload documents + const docsUploaded = await client + .trail(trailId) + .records() + .add(Data.fromString("Commercial invoice and packing list uploaded"), "event:documents_uploaded", "documents") + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + console.log("Docs operator added record", docsUploaded.output.sequenceNumber + ".\n"); + + // 4. Update metadata — awaiting export clearance + await client + .trail(trailId) + .updateMetadata("Status: Awaiting Export Clearance") + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + + // 5. Export clearance + const exportFiled = await client + .trail(trailId) + .records() + .add(Data.fromString("Export declaration filed with German customs"), "event:export_declaration_filed", "export") + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + + const exportCleared = await client + .trail(trailId) + .records() + .add(Data.fromString("Export clearance granted by Hamburg customs office"), "event:export_cleared", "export") + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + + console.log( + "Export broker added records", + exportFiled.output.sequenceNumber, + "and", + exportCleared.output.sequenceNumber + ".\n", + ); + + // 6. Update metadata — awaiting import clearance + await client + .trail(trailId) + .updateMetadata("Status: Awaiting Import Clearance") + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + + // 7. Attempt an inspection write before the inspector role exists + let inspectionDenied = false; + try { + await client + .trail(trailId) + .records() + .add(Data.fromString("Import broker attempted to record an inspection result"), "event:invalid_inspection_write", "inspection") + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + inspectionDenied = true; + } catch { + // Expected + } + assert.equal(inspectionDenied, false, "inspection-tagged writes should fail before an inspection-scoped capability exists"); + console.log("Inspection write was correctly denied before the inspector role existed.\n"); + + // 8. Create inspector role and add inspection record + await issueTaggedRecordRole(client, trailId, "Inspector", "inspection"); + + const inspectionDone = await client + .trail(trailId) + .records() + .add(Data.fromString("Customs inspection completed with no discrepancies"), "event:inspection_completed", "inspection") + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + console.log("Inspector added record", inspectionDone.output.sequenceNumber + ".\n"); + + // 9. Import clearance + const dutyAssessed = await client + .trail(trailId) + .records() + .add(Data.fromString("Import duty assessed and paid"), "event:duty_assessed", "import") + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + + const importCleared = await client + .trail(trailId) + .records() + .add(Data.fromString("Import clearance granted by Nairobi customs"), "event:import_cleared", "import") + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + + console.log( + "Import broker added records", + dutyAssessed.output.sequenceNumber, + "and", + importCleared.output.sequenceNumber + ".\n", + ); + + // 10. Mark as cleared + await client + .trail(trailId) + .updateMetadata("Status: Cleared") + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + + // 11. Freeze writes + await client + .trail(trailId) + .locking() + .updateWriteLock(TimeLock.withInfinite()) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + + const afterLock = await client.trail(trailId).get(); + console.log("Write lock after clearance:", afterLock.lockingConfig.writeLock, "\n"); + + // 12. Verify that late writes are rejected + let lateWriteSucceeded = false; + try { + await client + .trail(trailId) + .records() + .add(Data.fromString("Late customs note after the case was closed"), "event:late_note", "documents") + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + lateWriteSucceeded = true; + } catch { + // Expected + } + assert.equal(lateWriteSucceeded, false, "cleared customs trail should reject late writes after the final lock"); + + // 13. List all records + const firstPage = await client.trail(trailId).records().listPage(undefined, 20); + console.log("Recorded customs events:"); + for (const record of firstPage.records) { + console.log(` #${record.sequenceNumber} | ${record.data} | tag=${record.tag} | ${record.metadata}`); + } + + assert.equal(firstPage.records.length, 7, "expected 7 customs records including the initial case-opened record"); + + const trailState = await client.trail(trailId).get(); + assert.equal(trailState.updatableMetadata, "Status: Cleared", "customs case should finish in cleared state"); + + console.log("\nCustoms clearance completed successfully."); +} + +async function issueTaggedRecordRole( + client: any, + trailId: string, + roleName: string, + tag: string, +): Promise { + await client + .trail(trailId) + .access() + .forRole(roleName) + .create(PermissionSet.recordAdminPermissions(), new RoleTags([tag])) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + await client + .trail(trailId) + .access() + .forRole(roleName) + .issueCapability(new CapabilityIssueOptions(client.senderAddress())) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); +} 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 new file mode 100644 index 00000000..c41e6a33 --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/examples/src/real-world/02_clinical_trial.ts @@ -0,0 +1,263 @@ +// Copyright 2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +import { + 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"; + +/** + * # Clinical Trial Data-Integrity Example + * + * Models a Phase III clinical trial where an immutable audit trail + * guarantees data integrity, role-scoped access, and time-constrained oversight. + * + * - immutable_metadata: protocol identity and study description + * - updatable_metadata: current study phase (updated as the trial progresses) + * - record tags: enrollment, safety, efficacy, pk (added mid-study) + * - roles and capabilities: each role writes only its designated tag + * - time-constrained capabilities: Monitor access is windowed to the study period + * - locking: a deletion window protects recent records; a time-lock freezes the + * dataset after the Data Safety Board completes its review + * - read-only verification: a regulator inspects the trail without write access + */ +export async function clinicalTrial(): Promise { + console.log("=== Clinical Trial Data Integrity ===\n"); + + const client = await getFundedClient(); + + // 1. Create the trial trail + console.log("Creating the clinical-trial audit trail..."); + + const { output: created } = await client + .createTrail() + .withRecordTags(["enrollment", "safety", "efficacy"]) + .withTrailMetadata("Protocol CTR-2026-03742", "Phase III: Efficacy of Drug X vs Placebo in Moderate-to-Severe Asthma") + .withUpdatableMetadata("Phase: Enrollment") + .withLockingConfig( + new LockingConfig(LockingWindow.withCountBased(BigInt(3)), TimeLock.withNone(), TimeLock.withNone()), + ) + .withInitialRecordString("Clinical trial CTR-2026-03742 opened for enrollment", "event:trial_opened", "enrollment") + .finish() + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + + const trailId = created.id; + console.log("Trail created with ID", trailId, "\n"); + + // 2. Define roles with tag-scoped permissions + console.log("Defining study roles..."); + + await issueTaggedRecordRole(client, trailId, "Enroller", "enrollment"); + await issueTaggedRecordRole(client, trailId, "SafetyOfficer", "safety"); + await issueTaggedRecordRole(client, trailId, "EfficacyReviewer", "efficacy"); + + // Monitor can update metadata (study phase) — valid for 90 days + await client + .trail(trailId) + .access() + .forRole("Monitor") + .create(PermissionSet.metadataAdminPermissions()) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + + const nowMs = BigInt(Date.now()); + const studyEndMs = nowMs + BigInt(90 * 24 * 60 * 60 * 1000); + + await client + .trail(trailId) + .access() + .forRole("Monitor") + .issueCapability(new CapabilityIssueOptions(client.senderAddress(), nowMs, studyEndMs)) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + + console.log("Monitor capability issued (expires at timestamp", studyEndMs + ")\n"); + + // Data Safety Board can manage locking + await client + .trail(trailId) + .access() + .forRole("DataSafetyBoard") + .create(PermissionSet.lockingAdminPermissions()) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + await client + .trail(trailId) + .access() + .forRole("DataSafetyBoard") + .issueCapability(new CapabilityIssueOptions(client.senderAddress())) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + + // 3. Enrollment phase + console.log("--- Enrollment Phase ---"); + + const enrolled = await client + .trail(trailId) + .records() + .add(Data.fromString("Patient P-101 enrolled at Site Hamburg"), "event:patient_enrolled", "enrollment") + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + console.log("Enroller added record", enrolled.output.sequenceNumber + ".\n"); + + // 4. Safety and efficacy records + console.log("--- Study Data Collection ---"); + + const safetyEvent = await client + .trail(trailId) + .records() + .add(Data.fromString("Adverse event: mild headache reported by Patient P-101"), "event:adverse_event", "safety") + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + + const efficacyRecord = await client + .trail(trailId) + .records() + .add(Data.fromString("Week 12: FEV1 improvement of 320 mL over baseline for P-101"), "event:efficacy_observed", "efficacy") + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + + console.log( + "SafetyOfficer added record", + safetyEvent.output.sequenceNumber, + ", EfficacyReviewer added record", + efficacyRecord.output.sequenceNumber + ".\n", + ); + + // 5. Add a new tag mid-study (pharmacokinetics) + console.log("--- Mid-Study Amendment ---"); + + await client + .trail(trailId) + .tags() + .add("pk") + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + console.log('Added tag "pk" (pharmacokinetics) to the trail.'); + + await issueTaggedRecordRole(client, trailId, "PkAnalyst", "pk"); + + const pkRecord = await client + .trail(trailId) + .records() + .add(Data.fromString("PK analysis: Cmax reached at 2.4 h, half-life 8.7 h"), "event:pk_result", "pk") + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + console.log("PkAnalyst added record", pkRecord.output.sequenceNumber + ".\n"); + + // 6. Deletion window protects recent records + console.log("--- Deletion Window Enforcement ---"); + + let deleteSucceeded = false; + try { + await client + .trail(trailId) + .records() + .delete(pkRecord.output.sequenceNumber) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + deleteSucceeded = true; + } catch { + // Expected + } + assert.equal( + deleteSucceeded, + false, + "recent records must be protected by the count-based deletion window", + ); + console.log("Record", pkRecord.output.sequenceNumber, "is within the deletion window (newest 3) and cannot be deleted.\n"); + + // 7. Monitor updates study phase metadata + console.log("--- Metadata Update ---"); + + await client + .trail(trailId) + .updateMetadata("Phase: Data Review") + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + + const trail = await client.trail(trailId).get(); + console.log("Study phase updated to:", trail.updatableMetadata, "\n"); + + // 8. Data Safety Board locks the study dataset + console.log("--- Data Safety Board Lock ---"); + + const lockUntilMs = nowMs + BigInt(365 * 24 * 60 * 60 * 1000); // 1 year from now + + await client + .trail(trailId) + .locking() + .updateWriteLock(TimeLock.withUnlockAtMs(lockUntilMs)) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + + console.log("Write lock set to UnlockAtMs(" + lockUntilMs + ") — writes blocked until that timestamp.\n"); + + // Lock trail from deletion permanently + await client + .trail(trailId) + .locking() + .updateDeleteTrailLock(TimeLock.withInfinite()) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + + const finalLocking = await client.trail(trailId).get(); + console.log("Delete-trail lock set to", finalLocking.lockingConfig.deleteTrailLock.type, "— trail cannot be deleted.\n"); + + // 9. Regulator read-only verification + console.log("--- Regulator Verification ---"); + + const regulatorHandle = client.trail(trailId); + const onChain = await regulatorHandle.get(); + + console.log("Protocol:", onChain.immutableMetadata); + console.log("Phase: ", onChain.updatableMetadata); + console.log("Roles: ", onChain.roles.roles.map((r) => r.name)); + console.log("Tags: ", onChain.tags.map((t) => t.tag)); + + const firstPage = await regulatorHandle.records().listPage(undefined, 20); + console.log("\nVerified records (" + firstPage.records.length + " total):"); + for (const record of firstPage.records) { + console.log(` #${record.sequenceNumber} | tag=${record.tag} | ${record.metadata}`); + } + + // 10. Assertions + assert.equal(firstPage.records.length, 5, "expected 5 records (initial + enrolled + safety + efficacy + pk)"); + assert.ok(onChain.tags.some((t) => t.tag === "pk"), "the 'pk' tag must exist after mid-study amendment"); + assert.equal(onChain.lockingConfig.deleteRecordWindow.type, LockingWindow.withCountBased(BigInt(3)).type); + assert.equal(onChain.lockingConfig.deleteTrailLock.type, TimeLock.withInfinite().type); + assert.equal(onChain.lockingConfig.writeLock.type, TimeLock.withUnlockAtMs(lockUntilMs).type); + assert.equal(onChain.updatableMetadata, "Phase: Data Review"); + + console.log("\nClinical trial data-integrity verification completed successfully."); +} + +async function issueTaggedRecordRole( + client: any, + trailId: string, + roleName: string, + tag: string, +): Promise { + await client + .trail(trailId) + .access() + .forRole(roleName) + .create(PermissionSet.recordAdminPermissions(), new RoleTags([tag])) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); + await client + .trail(trailId) + .access() + .forRole(roleName) + .issueCapability(new CapabilityIssueOptions(client.senderAddress())) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(client); +} diff --git a/bindings/wasm/audit_trail_wasm/examples/src/tests.ts b/bindings/wasm/audit_trail_wasm/examples/src/tests.ts index cbcf6c85..7d665ee0 100644 --- a/bindings/wasm/audit_trail_wasm/examples/src/tests.ts +++ b/bindings/wasm/audit_trail_wasm/examples/src/tests.ts @@ -3,29 +3,62 @@ import { afterEach, describe, it } from "mocha"; -import { createTrail } from "./01_create_trail"; -import { fetchTrail } from "./02_fetch_trail"; -import { addAndListRecords } from "./03_add_and_list_records"; -import { deleteRecordsBatch } from "./04_delete_records_batch"; +import { createAuditTrail } from "./01_create_audit_trail"; +import { addAndReadRecords } from "./02_add_and_read_records"; +import { updateMetadata } from "./03_update_metadata"; +import { configureLocking } from "./04_configure_locking"; +import { manageAccess } from "./05_manage_access"; +import { deleteRecords } from "./06_delete_records"; +import { accessReadOnlyMethods } from "./07_access_read_only_methods"; +import { deleteAuditTrail } from "./08_delete_audit_trail"; +import { taggedRecords } from "./advanced/09_tagged_records"; +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"; -describe("Audit trail wasm node examples", function() { +describe("Audit trail wasm node examples", function () { afterEach(() => { console.log("\n----------------------------------------------------\n"); }); it("creates a trail", async () => { - await createTrail(); + await createAuditTrail(); }); - - it("fetches a trail", async () => { - await fetchTrail(); + it("adds and reads records", async () => { + await addAndReadRecords(); }); - - it("adds and lists records", async () => { - await addAndListRecords(); + it("updates metadata", async () => { + await updateMetadata(); }); - - it("deletes records in batch", async () => { - await deleteRecordsBatch(); + it("configures locking", async () => { + await configureLocking(); + }); + it("manages access", async () => { + await manageAccess(); + }); + it("deletes records", async () => { + await deleteRecords(); + }); + it("accesses read-only methods", async () => { + await accessReadOnlyMethods(); + }); + it("deletes an audit trail", async () => { + await deleteAuditTrail(); + }); + it("uses tagged records", async () => { + await taggedRecords(); + }); + it("constrains capabilities", async () => { + await capabilityConstraints(); + }); + it("manages record tags", async () => { + await manageRecordTags(); + }); + it("runs customs clearance example", async () => { + await customsClearance(); + }); + it("runs clinical trial example", async () => { + await clinicalTrial(); }); -}); +}); \ No newline at end of file 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 79e17271..1a7bb9d8 100644 --- a/bindings/wasm/audit_trail_wasm/examples/src/web-main.ts +++ b/bindings/wasm/audit_trail_wasm/examples/src/web-main.ts @@ -1,31 +1,54 @@ // Copyright 2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -import { createTrail } from "./01_create_trail"; -import { fetchTrail } from "./02_fetch_trail"; -import { addAndListRecords } from "./03_add_and_list_records"; -import { deleteRecordsBatch } from "./04_delete_records_batch"; +import { createAuditTrail } from "./01_create_audit_trail"; +import { addAndReadRecords } from "./02_add_and_read_records"; +import { updateMetadata } from "./03_update_metadata"; +import { configureLocking } from "./04_configure_locking"; +import { manageAccess } from "./05_manage_access"; +import { deleteRecords } from "./06_delete_records"; +import { accessReadOnlyMethods } from "./07_access_read_only_methods"; +import { deleteAuditTrail } from "./08_delete_audit_trail"; +import { taggedRecords } from "./advanced/09_tagged_records"; +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"; export async function main(example?: string) { - const argument = example ?? process.argv?.[2]?.toLowerCase(); + const argument = example ?? new URLSearchParams(window.location.search).get("example")?.toLowerCase(); if (!argument) { - throw new Error("Please specify an example name, e.g. '01_create_trail'"); + throw new Error("Please specify an example name, e.g. '01_create_audit_trail'"); } switch (argument) { - case "01_create_trail": - return createTrail(); - case "02_fetch_trail": - return fetchTrail(); - case "03_add_and_list_records": - return addAndListRecords(); - case "04_delete_records_batch": - return deleteRecordsBatch(); + case "01_create_audit_trail": + return createAuditTrail(); + case "02_add_and_read_records": + return addAndReadRecords(); + case "03_update_metadata": + return updateMetadata(); + case "04_configure_locking": + return configureLocking(); + case "05_manage_access": + return manageAccess(); + case "06_delete_records": + return deleteRecords(); + case "07_access_read_only_methods": + return accessReadOnlyMethods(); + case "08_delete_audit_trail": + return deleteAuditTrail(); + case "09_tagged_records": + return taggedRecords(); + case "10_capability_constraints": + return capabilityConstraints(); + case "11_manage_record_tags": + return manageRecordTags(); + case "01_customs_clearance": + return customsClearance(); + case "02_clinical_trial": + return clinicalTrial(); default: throw new Error(`Unknown example name: '${argument}'`); } -} - -main().catch((error) => { - console.error("Example error:", error); -}); +} \ No newline at end of file diff --git a/bindings/wasm/audit_trail_wasm/lib/tsconfig.json b/bindings/wasm/audit_trail_wasm/lib/tsconfig.json index 5c1e30af..7522c000 100644 --- a/bindings/wasm/audit_trail_wasm/lib/tsconfig.json +++ b/bindings/wasm/audit_trail_wasm/lib/tsconfig.json @@ -18,4 +18,4 @@ "outDir": "../node", "declarationDir": "../node" } -} +} \ No newline at end of file diff --git a/bindings/wasm/audit_trail_wasm/lib/tsconfig.web.json b/bindings/wasm/audit_trail_wasm/lib/tsconfig.web.json index 1621088d..9459a549 100644 --- a/bindings/wasm/audit_trail_wasm/lib/tsconfig.web.json +++ b/bindings/wasm/audit_trail_wasm/lib/tsconfig.web.json @@ -19,4 +19,4 @@ "declarationDir": "../web", "module": "ES2022" } -} +} \ No newline at end of file diff --git a/bindings/wasm/audit_trail_wasm/package.json b/bindings/wasm/audit_trail_wasm/package.json index e0adc952..d8efcb5f 100644 --- a/bindings/wasm/audit_trail_wasm/package.json +++ b/bindings/wasm/audit_trail_wasm/package.json @@ -22,11 +22,16 @@ "build:nodejs": "npm run build:src:nodejs && npm run bundle:nodejs && wasm-opt -O node/audit_trail_wasm_bg.wasm -o node/audit_trail_wasm_bg.wasm", "build:web": "npm run build:src && npm run bundle:web && wasm-opt -O web/audit_trail_wasm_bg.wasm -o web/audit_trail_wasm_bg.wasm", "build:docs": "typedoc && npm run fix_docs", - "build:examples:web": "tsc --project ./examples/tsconfig.web.json || node ../build/replace_paths ./examples/tsconfig.web.json dist/web audit_trail_wasm/examples resolve", + "build:examples:web": "tsc --project ./examples/tsconfig.web.json && node ../build/replace_paths ./tsconfig.web.json dist audit_trail_wasm/examples resolve", "build": "npm run build:web && npm run build:nodejs && npm run build:docs", "example:node": "ts-node --project ./examples/tsconfig.node.json -r tsconfig-paths/register ./examples/src/main.ts", "test": "npm run test:node", "test:node": "ts-mocha -r tsconfig-paths/register -p ./examples/tsconfig.node.json ./examples/src/tests.ts --parallel --jobs 4 --retries 3 --timeout 180000 --exit", + "test:browser": "start-server-and-test example:web http://0.0.0.0:5173 'cypress run --headless'", + "test:browser:firefox": "start-server-and-test example:web http://0.0.0.0:5173 'cypress run --headless --browser firefox'", + "test:browser:chrome": "start-server-and-test example:web http://0.0.0.0:5173 'cypress run --headless --browser chrome'", + "example:web": "npm i --prefix ./cypress/app/ && npm run dev --prefix ./cypress/app/ -- --host", + "cypress": "cypress open", "fmt": "dprint fmt", "fix_docs": "find ./docs/wasm/ -type f -name '*.md' -exec sed -E -i.bak -e 's/(\\.md?#([^#]*)?)#/\\1/' {} ';' -exec rm {}.bak ';'" }, @@ -40,9 +45,11 @@ "devDependencies": { "@types/mocha": "^9.1.0", "@types/node": "^22.0.0", + "cypress": "^14.2.0", "dprint": "^0.33.0", "mocha": "^9.2.0", "rimraf": "^6.0.1", + "start-server-and-test": "^2.0.11", "ts-mocha": "^9.0.2", "ts-node": "^10.9.2", "tsconfig-paths": "^4.1.0", @@ -57,6 +64,9 @@ "peerDependencies": { "@iota/iota-sdk": "^1.11.0" }, + "config": { + "CYPRESS_VERIFY_TIMEOUT": 100000 + }, "engines": { "node": ">=20" } diff --git a/examples/audit-trail/05_manage_access.rs b/examples/audit-trail/05_manage_access.rs index d8584c48..7c8155de 100644 --- a/examples/audit-trail/05_manage_access.rs +++ b/examples/audit-trail/05_manage_access.rs @@ -12,7 +12,7 @@ use product_common::core_client::CoreClient; /// 1. Create and update a custom role. /// 2. Issue a constrained capability for that role. /// 3. Revoke one capability and destroy another. -/// 4. Remove the role after its capabilities are no longer needed. +/// 4. Remove the role after its capability lifecycle is complete. #[tokio::main] async fn main() -> Result<()> { println!("=== Audit Trail: Manage Access ===\n"); @@ -96,6 +96,13 @@ async fn main() -> Result<()> { .await?; println!("Destroyed capability {}\n", disposable_capability.capability_id); + trail + .access() + .cleanup_revoked_capabilities() + .build_and_execute(&client) + .await?; + println!("Cleaned up revoked capability registry entries.\n"); + role.delete().build_and_execute(&client).await?; let after_delete = trail.get().await?; diff --git a/examples/audit-trail/README.md b/examples/audit-trail/README.md index e223f9b2..433bad13 100644 --- a/examples/audit-trail/README.md +++ b/examples/audit-trail/README.md @@ -62,7 +62,7 @@ IOTA_AUDIT_TRAIL_PKG_ID=0x... IOTA_TF_COMPONENTS_PKG_ID=0x... cargo run --releas | [02_add_and_read_records](https://github.com/iotaledger/notarization/tree/main/examples/audit-trail/02_add_and_read_records.rs) | Adds follow-up records to a trail, then loads them back individually and through paginated reads. | | [03_update_metadata](https://github.com/iotaledger/notarization/tree/main/examples/audit-trail/03_update_metadata.rs) | Updates and clears the trail's mutable metadata while preserving immutable metadata. | | [04_configure_locking](https://github.com/iotaledger/notarization/tree/main/examples/audit-trail/04_configure_locking.rs) | Configures write and delete locks, then shows how those rules affect record creation. | -| [05_manage_access](https://github.com/iotaledger/notarization/tree/main/examples/audit-trail/05_manage_access.rs) | Creates, updates, and deletes roles while issuing, revoking, and destroying capabilities. | +| [05_manage_access](https://github.com/iotaledger/notarization/tree/main/examples/audit-trail/05_manage_access.rs) | Creates and updates a role, then demonstrates constrained capability issuance, revoke and destroy flows, denylist cleanup, and final role removal. | | [06_delete_records](https://github.com/iotaledger/notarization/tree/main/examples/audit-trail/06_delete_records.rs) | Deletes an individual record and then removes the remaining records in a batch. | | [07_access_read_only_methods](https://github.com/iotaledger/notarization/tree/main/examples/audit-trail/07_access_read_only_methods.rs) | Reads back trail metadata, locking state, record counts, and paginated record data. | | [08_delete_audit_trail](https://github.com/iotaledger/notarization/tree/main/examples/audit-trail/08_delete_audit_trail.rs) | Empties a trail and then deletes it, showing that non-empty trails cannot be removed. | diff --git a/examples/audit-trail/real-world/01_customs_clearance.rs b/examples/audit-trail/real-world/01_customs_clearance.rs index 02586b57..68837692 100644 --- a/examples/audit-trail/real-world/01_customs_clearance.rs +++ b/examples/audit-trail/real-world/01_customs_clearance.rs @@ -259,7 +259,10 @@ async fn main() -> Result<()> { ); } - ensure!(first_page.records.len() == 6, "expected 6 customs records"); + ensure!( + first_page.records.len() == 7, + "expected 7 customs records including the initial case-opened record" + ); ensure!( trail.get().await?.updatable_metadata.as_deref() == Some("Status: Cleared"), "customs case should finish in cleared state" diff --git a/notarization-move/Move.history.json b/notarization-move/Move.history.json index e2ab094c..30d7c448 100644 --- a/notarization-move/Move.history.json +++ b/notarization-move/Move.history.json @@ -5,14 +5,14 @@ "mainnet": "6364aad5" }, "envs": { - "6364aad5": [ - "0x909ce9dcd9a5e97b7b8884fac8e018fad9dece348bf73837379b8694ff684cf3" + "daf90477": [ + "0x72c8433b88e6bdee0eb02a257fdebd0ec2b6c990043f35b155cb4c5cf727fdca" ], "2304aa97": [ "0x00412bd469b7f980227c6c574090348239852e43aa07818b315854fdd8a2d25f" ], - "daf90477": [ - "0x72c8433b88e6bdee0eb02a257fdebd0ec2b6c990043f35b155cb4c5cf727fdca" + "6364aad5": [ + "0x909ce9dcd9a5e97b7b8884fac8e018fad9dece348bf73837379b8694ff684cf3" ] } } \ No newline at end of file From cd093b1f763524a8fa0df873977de99aae0d16d5 Mon Sep 17 00:00:00 2001 From: Yasir Date: Wed, 8 Apr 2026 15:35:50 +0300 Subject: [PATCH 147/189] docs: refine audit trail examples and guides --- CLAUDE.md | 27 ++++ .../src/core/types/RoleMap-README.md | 131 +++++++++--------- audit-trail-rs/src/core/types/role_map.rs | 17 +-- .../wasm/audit_trail_wasm/cypress.config.ts | 2 +- .../cypress/app/src/audit_trail.ts | 2 +- .../audit_trail_wasm/cypress/app/src/main.ts | 2 +- .../cypress/app/src/vite-env.d.ts | 2 +- .../cypress/app/vite.config.js | 2 +- .../audit_trail_wasm/cypress/e2e/tests.cy.js | 2 +- .../wasm/audit_trail_wasm/examples/README.md | 38 ++--- .../examples/src/01_create_audit_trail.ts | 2 +- .../examples/src/02_add_and_read_records.ts | 2 +- .../examples/src/03_update_metadata.ts | 2 +- .../examples/src/04_configure_locking.ts | 9 +- .../src/07_access_read_only_methods.ts | 11 +- .../examples/src/08_delete_audit_trail.ts | 2 +- .../src/advanced/09_tagged_records.ts | 10 +- .../src/advanced/10_capability_constraints.ts | 2 +- .../src/advanced/11_manage_record_tags.ts | 6 +- .../audit_trail_wasm/examples/src/main.ts | 2 +- .../src/real-world/01_customs_clearance.ts | 24 +++- .../src/real-world/02_clinical_trial.ts | 31 ++++- .../audit_trail_wasm/examples/src/tests.ts | 4 +- .../audit_trail_wasm/examples/src/web-main.ts | 2 +- examples/audit-trail/README.md | 62 ++++----- .../real-world/02_clinical_trial.rs | 4 +- examples/notarization/README.md | 8 +- 27 files changed, 245 insertions(+), 163 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 18c8a6f9..4f84d9d1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,6 +9,7 @@ IOTA Notarization enables creation of immutable, on-chain records for arbitrary ## Common Commands ### Build & Check + ```bash cargo build --workspace --tests --examples cargo check -p notarization-rs @@ -16,6 +17,7 @@ cargo check -p audit-trail-rs ``` ### Test + ```bash # Tests must run single-threaded (IOTA sandbox requirement) cargo test --workspace --release -- --test-threads=1 @@ -28,6 +30,7 @@ iota move test ``` ### Lint & Format + ```bash cargo clippy --all-targets --all-features cargo fmt --all @@ -35,6 +38,7 @@ cargo fmt --all -- --check # check only ``` ### WASM Bindings (in bindings/wasm/notarization_wasm/ or audit_trail_wasm/) + ```bash npm install npm run build @@ -43,6 +47,7 @@ npm run test:browser # Cypress browser tests ``` ### Move Scripts + ```bash # From notarization-move/ or audit-trail-move/ ./scripts/publish_package.sh @@ -50,9 +55,11 @@ npm run test:browser # Cypress browser tests ``` ### Running Examples + Examples require the relevant Move package to be published first. **Notarization examples** — from the repo root: + ```bash # Publish the package and capture the package ID export IOTA_NOTARIZATION_PKG_ID=$(./notarization-move/scripts/publish_package.sh) @@ -60,13 +67,16 @@ export IOTA_NOTARIZATION_PKG_ID=$(./notarization-move/scripts/publish_package.sh # Run a specific example cargo run --release --example ``` + To run all notarization examples: + ```bash # Make sure IOTA_NOTARIZATION_PKG_ID is set as shown above ./examples/run.sh ``` **Audit Trail examples** — from the repo root: + ```bash # Publish the package; on localnet both vars are set to the same package ID eval $(./audit-trail-move/scripts/publish_package.sh) @@ -76,22 +86,26 @@ cargo run --release --example ``` The `eval` form is required because the publish script prints shell `export` statements for two variables: + - `IOTA_AUDIT_TRAIL_PKG_ID` — the audit trail package ID - `IOTA_TF_COMPONENTS_PKG_ID` — the TfComponents package ID (equals `IOTA_AUDIT_TRAIL_PKG_ID` on localnet) ## Developing Examples ### Adding a new example + 1. Create the source file under `examples/notarization/` or `examples/audit-trail/`. 2. Add an `[[example]]` entry to `examples/Cargo.toml` pointing to the new file. 3. Use `examples::get_funded_notarization_client()` (notarization) or `examples::get_funded_audit_trail_client()` (audit trail) from `examples/utils/utils.rs` to obtain a funded, signed client. Do not inline client construction in example files. ### Audit Trail example patterns + Reference implementation: `examples/audit-trail/01_create_audit_trail.rs` **Client setup** — `get_funded_audit_trail_client()` reads `IOTA_AUDIT_TRAIL_PKG_ID` and `IOTA_TF_COMPONENTS_PKG_ID` from the environment and returns `AuditTrailClient`. **Creating a trail** — use the builder returned by `client.create_trail()`: + ```rust let created = client .create_trail() @@ -103,9 +117,11 @@ let created = client .await? .output; // TrailCreated { trail_id, creator, timestamp } ``` + The creator automatically receives an Admin capability object in their wallet. **Defining a role** — use the trail handle's access API with the implicit Admin capability: + ```rust client .trail(trail_id) @@ -115,9 +131,11 @@ client .build_and_execute(&client) .await?; ``` + `PermissionSet` convenience constructors: `admin_permissions()`, `record_admin_permissions()`, `locking_admin_permissions()`, `tag_admin_permissions()`, `cap_admin_permissions()`, `metadata_admin_permissions()`. **Issuing a capability** — mint a capability object for a role: + ```rust let cap = client .trail(trail_id) @@ -128,11 +146,13 @@ let cap = client .await? .output; // CapabilityIssued { capability_id, target_key, role, issued_to, valid_from, valid_until } ``` + Use `CapabilityIssueOptions { issued_to, valid_from_ms, valid_until_ms }` to restrict who may use the capability or set a validity window. **Key types** (from `audit_trail::core::types`): `Data`, `InitialRecord`, `ImmutableMetadata`, `LockingConfig`, `LockingWindow`, `TimeLock`, `Permission`, `PermissionSet`, `CapabilityIssueOptions`, `RoleTags`. ### Notarization example patterns + Reference implementations: `examples/notarization/01_create_locked_notarization.rs` and `examples/notarization/02_create_dynamic_notarization.rs`. Use `examples::get_funded_notarization_client()` to get a `NotarizationClient`. Read `audit-trail-rs/tests/e2e/` for detailed usage of every API surface. @@ -152,26 +172,33 @@ The root `Cargo.toml` defines a workspace with members: `notarization-rs`, `audi ## Architecture ### Client Layer Pattern + Both `notarization-rs` and `audit-trail-rs` follow the same pattern: + - **Full client** (`NotarizationClient` / `AuditTrailClient`): Signs and submits transactions - **Read-only client** (`NotarizationClientReadOnly` / `AuditTrailClientReadOnly`): Read-only state inspection - Clients wrap a `product_common` transaction builder that supports `.build()`, `.build_and_execute()`, and `.execute_with_gas_station()` ### Builder Pattern (Type-State) + Notarization creation uses a `NotarizationBuilder` with phantom type states to enforce valid configurations at compile time. Separate builder paths exist for **Dynamic** (mutable, transferable) vs **Locked** (immutable, non-transferable) notarizations. ### Method Types + - **Dynamic**: State and metadata are updatable after creation; supports transfer locks - **Locked**: State and metadata are immutable; supports time-based destruction ### Lock System + - **Transfer locks**: `None`, `UnlockAt(epoch)`, `UntilDestroyed` - **Delete locks**: Restrict when a notarization can be destroyed ### Cross-Platform Compilation + Code uses `#[cfg(target_arch = "wasm32")]` guards to conditionally compile for WASM. Features `send-sync`, `gas-station`, `default-http-client`, and `irl` control optional capabilities. ### Key External Dependencies + - `iota-sdk` (v1.19.1, from IOTA git) — on-chain interaction - `iota_interaction` / `iota_interaction_rust` / `iota_interaction_ts` — from `product-core` repo, `feat/tf-compoenents-dev` branch - `product_common` — transaction builder abstraction from `product-core` diff --git a/audit-trail-rs/src/core/types/RoleMap-README.md b/audit-trail-rs/src/core/types/RoleMap-README.md index 456a491c..4fec6838 100644 --- a/audit-trail-rs/src/core/types/RoleMap-README.md +++ b/audit-trail-rs/src/core/types/RoleMap-README.md @@ -7,7 +7,7 @@ operations by combining two primitives: - **Capabilities** — on-chain objects held by users, each linked to one role. Every operation on a trail (adding a record, deleting a role, revoking a -capability, …) requires the caller to present a `Capability`. The audit trail +capability, …) requires the caller to present a `Capability`. The audit trail validates the capability before allowing the operation. --- @@ -18,14 +18,14 @@ validates the capability before allowing the operation. A role is a named and configurable set of `Permission` values, for example: -| Role name | Permissions | -|:----------------|:--------------------------------------------| -| `Admin` | AddRoles, UpdateRoles, DeleteRoles, AddCapabilities, RevokeCapabilities, AddRecordTags, DeleteRecordTags | -| `RecordAdmin` | AddRecord, DeleteRecord, CorrectRecord | -| `LockingAdmin` | UpdateLockingConfig (and sub-variants) | -| `Auditor` | *(read-only — no write permissions needed)* | +| Role name | Permissions | +| :------------- | :------------------------------------------------------------------------------------------------------- | +| `Admin` | AddRoles, UpdateRoles, DeleteRoles, AddCapabilities, RevokeCapabilities, AddRecordTags, DeleteRecordTags | +| `RecordAdmin` | AddRecord, DeleteRecord, CorrectRecord | +| `LockingAdmin` | UpdateLockingConfig (and sub-variants) | +| `Auditor` | _(read-only — no write permissions needed)_ | -Roles are identified by a unique string name within the trail. Multiple +Roles are identified by a unique string name within the trail. Multiple capabilities can be issued for the same role, to allow users or services to share that access level. @@ -33,27 +33,28 @@ Roles may optionally carry a `RoleTags` allowlist (see [Record Tags](#record-tag ### Capabilities -A `Capability` is an on-chain object owned by a wallet address. It records: +A `Capability` is an on-chain object owned by a wallet address. It records: -| Field | Meaning | -|:--------------|:----------------------------------------------------------------------| -| `target_key` | The `ObjectID` of the trail this capability is valid for. | -| `role` | The role name — determines which permissions the holder has. | -| `issued_to` | Optional address binding; only that address may present the cap. | -| `valid_from` | Optional Unix-ms timestamp before which the cap is not yet active. | -| `valid_until` | Optional Unix-ms timestamp after which the cap expires. | +| Field | Meaning | +| :------------ | :----------------------------------------------------------------- | +| `target_key` | The `ObjectID` of the trail this capability is valid for. | +| `role` | The role name — determines which permissions the holder has. | +| `issued_to` | Optional address binding; only that address may present the cap. | +| `valid_from` | Optional Unix-ms timestamp before which the cap is not yet active. | +| `valid_until` | Optional Unix-ms timestamp after which the cap expires. | -Possessing a capability does **not** automatically grant access. The audit trail +Possessing a capability does **not** automatically grant access. The audit trail validates all fields above on every call before the operation is executed. ### The Admin Role When a trail is created, the access control registry is initialized with exactly one role — -the **initial admin role** (named `"Admin"`). A corresponding capability +the **initial admin role** (named `"Admin"`). A corresponding capability object is minted and transferred to the trail creator (or a custom address supplied via `with_admin`). The Admin role is protected by two invariants: + 1. It can **never be deleted**. 2. Although its permission set can be updated, it needs to include a minimum set of permissions to manage the trail's access control (AddRoles, UpdateRoles, DeleteRoles, @@ -112,7 +113,7 @@ Admin Capability + revoke_capability(cap_id, valid_until) ``` Please note: Revoked capability objects still exist on-chain but will be rejected by -`assert_capability_valid`. The holder can no longer use it. +`assert_capability_valid`. The holder can no longer use it. --- @@ -239,19 +240,19 @@ client ## Record Tags and RoleTags -Tags are string labels that can be attached to individual records. They are +Tags are string labels that can be attached to individual records. They are managed through a **tag registry** on the trail: a tag must be registered before it can be used on a record or referenced by a role. ### Why use tags? -Tags enable fine-grained access control beyond simple permission checks. For +Tags enable fine-grained access control beyond simple permission checks. For example, a legal department may only be allowed to access records tagged `"legal"`, while the finance team works with records tagged `"finance"`. ### How tags interact with roles -A role may carry an optional `RoleTags` allowlist. When a capability holder +A role may carry an optional `RoleTags` allowlist. When a capability holder adds a record with a tag, the audit trail checks that: 1. The tag is registered in the trail's tag registry. @@ -263,11 +264,12 @@ If either check fails the transaction is rejected. The same checks apply when a record having a tag is updated or deleted. Please note: -* Tags only restrict the use of tagged records to roles that explicitly + +- Tags only restrict the use of tagged records to roles that explicitly grant access to those tags in the associated `RoleTags` allowlist. -* Tags do not grant access permission themselves. A role still needs the relevant +- Tags do not grant access permission themselves. A role still needs the relevant permissions (e.g. `AddRecord`) to perform operations on tagged records. -* A role without any `RoleTags` can operate on any record not having tags, as long +- A role without any `RoleTags` can operate on any record not having tags, as long as it has the necessary permissions. ### Example — tagged records @@ -312,35 +314,35 @@ The checks run in the order listed below; the transaction aborts on the ### 1 — `ECapabilityTargetKeyMismatch` The capability's `target_key` must match the `target_key` of the RoleMap -(which is typically the `ObjectID` of the audit trail). This prevents a +(which is typically the `ObjectID` of the audit trail). This prevents a capability issued for one trail from being used on a different trail. ### 2 — `ERoleDoesNotExist` -The role name stored in the capability must still exist in the RoleMap. If +The role name stored in the capability must still exist in the RoleMap. If an admin deleted the role after the capability was issued, the capability becomes unusable — even though it was never explicitly revoked. ### 3 — `ECapabilityPermissionDenied` The role's current permission set must contain the permission required by the -operation being performed. For example, calling `add_record` requires the -`AddRecord` permission. If the role was updated after the capability was +operation being performed. For example, calling `add_record` requires the +`AddRecord` permission. If the role was updated after the capability was issued and the required permission was removed, existing capabilities for that role will start failing this check. ### 4 — `ECapabilityHasBeenRevoked` The capability's ID must **not** appear in the `revoked_capabilities` -denylist. A capability that has been revoked via `revoke_capability` (or +denylist. A capability that has been revoked via `revoke_capability` (or `revoke_initial_admin_capability`) is permanently rejected, even if it is -still within its validity window. See +still within its validity window. See [Managing Revoked Capabilities](#managing-revoked-capabilities) for details. ### 5 — `ECapabilityTimeConstraintsNotMet` This check only runs when the capability has a `valid_from` and/or -`valid_until` field set. The current on-chain clock time must satisfy: +`valid_until` field set. The current on-chain clock time must satisfy: - `valid_from`: current time **>=** `valid_from` (the capability is not yet active before this timestamp). @@ -354,7 +356,7 @@ considered valid at any point in time. This check only runs when the capability has a non-empty `issued_to` field. The address of the transaction sender must match the `issued_to` address -stored in the capability. This binds the capability to a specific wallet, +stored in the capability. This binds the capability to a specific wallet, preventing it from being used by anyone else even if the on-chain object is transferred. @@ -363,7 +365,7 @@ If `issued_to` is not set, any holder of the capability object may use it. ### 7 — `ERecordTagNotDefined` / `ERecordTagNotAllowed` This check is performed by the audit trail **after** all `RoleMap` checks -(1–6) have passed. It only applies to record operations (add, correct, +(1–6) have passed. It only applies to record operations (add, correct, delete) that involve a tagged record. When a record carries a tag, two additional conditions must hold: @@ -371,24 +373,24 @@ When a record carries a tag, two additional conditions must hold: 1. The tag must be registered in the trail's **tag registry** (`ERecordTagNotDefined`). 2. The role associated with the capability must include the tag in its - `RoleTags` allowlist (`ERecordTagNotAllowed`). A role without any + `RoleTags` allowlist (`ERecordTagNotAllowed`). A role without any `RoleTags` is **not** permitted to operate on tagged records. -If the record has no tag, this check is skipped. See +If the record has no tag, this check is skipped. See [Record Tags and RoleTags](#record-tags-and-roletags) for a full explanation and examples. ### Summary -| # | Check | Error | Skippable | -|:--|:------------------------|:------------------------------------|:----------| -| 1 | `target_key` mismatch | `ECapabilityTargetKeyMismatch` | No | -| 2 | Role does not exist | `ERoleDoesNotExist` | No | -| 3 | Permission not in role | `ECapabilityPermissionDenied` | No | -| 4 | ID in revoked denylist | `ECapabilityHasBeenRevoked` | No | -| 5 | Outside validity window | `ECapabilityTimeConstraintsNotMet` | Yes — only if `valid_from` or `valid_until` is set | -| 6 | `issued_to` mismatch | `ECapabilityIssuedToMismatch` | Yes — only if `issued_to` is set | -| 7 | Record tag not allowed | `ERecordTagNotDefined` / `ERecordTagNotAllowed` | Yes — only for record operations on tagged records | +| # | Check | Error | Skippable | +| :- | :---------------------- | :---------------------------------------------- | :------------------------------------------------- | +| 1 | `target_key` mismatch | `ECapabilityTargetKeyMismatch` | No | +| 2 | Role does not exist | `ERoleDoesNotExist` | No | +| 3 | Permission not in role | `ECapabilityPermissionDenied` | No | +| 4 | ID in revoked denylist | `ECapabilityHasBeenRevoked` | No | +| 5 | Outside validity window | `ECapabilityTimeConstraintsNotMet` | Yes — only if `valid_from` or `valid_until` is set | +| 6 | `issued_to` mismatch | `ECapabilityIssuedToMismatch` | Yes — only if `issued_to` is set | +| 7 | Record tag not allowed | `ERecordTagNotDefined` / `ERecordTagNotAllowed` | Yes — only for record operations on tagged records | --- @@ -397,7 +399,7 @@ and examples. ### The `revoked_capabilities` Denylist When a capability is revoked it is **not deleted from the chain** — the -on-chain `Capability` object still exists in the holder's wallet. Instead, +on-chain `Capability` object still exists in the holder's wallet. Instead, the capability's ID is added to a **denylist** stored inside the audit trail. During every call to an access restricted audit trail function, the internally called `assert_capability_valid` function checks the denylist and rejects any capability whose @@ -405,12 +407,12 @@ ID appears in it (error `ECapabilityHasBeenRevoked`). The denylist approach (as opposed to an allowlist of all issued capabilities) was chosen deliberately: it keeps on-chain storage proportional to the number -of *currently revoked* capabilities rather than the total number ever issued. +of _currently revoked_ capabilities rather than the total number ever issued. This is important for deployments that issue large numbers of capabilities over time. Each denylist entry maps a revoked capability ID to a `valid_until` timestamp -(Unix milliseconds). If the revoked capability had no `valid_until` field, the +(Unix milliseconds). If the revoked capability had no `valid_until` field, the stored value is `0`, which signals "no expiry — keep in the denylist indefinitely". @@ -425,10 +427,10 @@ denylist. This has an important consequence for revocation: **once a capability's `valid_until` timestamp has passed, the capability is naturally expired and -can no longer be used — even if it was never explicitly revoked.** Its +can no longer be used — even if it was never explicitly revoked.** Its denylist entry therefore becomes redundant and can be safely removed. -The `cleanup_revoked_capabilities` function exploits this property. It +The `cleanup_revoked_capabilities` function exploits this property. It iterates through the denylist and removes every entry whose stored `valid_until` value is **non-zero** and **less than** the current clock time. Entries with `valid_until == 0` (capabilities that were issued without an @@ -454,7 +456,7 @@ This design shifts the bookkeeping responsibility to the user: least the capability `ID`, the `role` it was issued for, the `issued_to` address (if any), and the `valid_from` / `valid_until` timestamps. 2. **When revoking**, supply the correct capability ID and its `valid_until` - value (via the `cap_to_revoke_valid_until` parameter). The + value (via the `cap_to_revoke_valid_until` parameter). The `revoke_capability` function does **not** verify that the supplied ID actually refers to a real, previously-issued capability — if you pass a random ID, it will be silently added to the denylist without error. @@ -479,7 +481,7 @@ until the capability object is explicitly destroyed. ### Cleaning Up the Denylist Over time the denylist can accumulate entries for capabilities that have -already naturally expired. The `cleanup_revoked_capabilities` function +already naturally expired. The `cleanup_revoked_capabilities` function removes these stale entries: 1. It walks through every entry in the `revoked_capabilities` linked table. @@ -509,18 +511,19 @@ permission. `PermissionSet` provides convenience constructors for common role profiles: -| Constructor | Permissions | -|:------------------------------|:-------------------------------------------------------------------------------| -| `admin_permissions()` | AddRoles, UpdateRoles, DeleteRoles, AddCapabilities, RevokeCapabilities, AddRecordTags, DeleteRecordTags | -| `record_admin_permissions()` | AddRecord, DeleteRecord, CorrectRecord | -| `locking_admin_permissions()` | UpdateLockingConfig (and all sub-variants) | -| `cap_admin_permissions()` | AddCapabilities, RevokeCapabilities | -| `tag_admin_permissions()` | AddRecordTags, DeleteRecordTags | -| `metadata_admin_permissions()`| UpdateMetadata, DeleteMetadata | +| Constructor | Permissions | +| :----------------------------- | :------------------------------------------------------------------------------------------------------- | +| `admin_permissions()` | AddRoles, UpdateRoles, DeleteRoles, AddCapabilities, RevokeCapabilities, AddRecordTags, DeleteRecordTags | +| `record_admin_permissions()` | AddRecord, DeleteRecord, CorrectRecord | +| `locking_admin_permissions()` | UpdateLockingConfig (and all sub-variants) | +| `cap_admin_permissions()` | AddCapabilities, RevokeCapabilities | +| `tag_admin_permissions()` | AddRecordTags, DeleteRecordTags | +| `metadata_admin_permissions()` | UpdateMetadata, DeleteMetadata | Please note: -* These constructors are just for convenience and do not enforce any invariants. + +- These constructors are just for convenience and do not enforce any invariants. For example, you could (not recommended) create a role named `NormalUser` with - `PermissionSet::admin_permissions()`. -* You can create custom permission sets by constructing a `PermissionSet` with - an arbitrary combination of permissions. \ No newline at end of file + `PermissionSet::admin_permissions()`. +- You can create custom permission sets by constructing a `PermissionSet` with + an arbitrary combination of permissions. diff --git a/audit-trail-rs/src/core/types/role_map.rs b/audit-trail-rs/src/core/types/role_map.rs index cc66d963..f0c9e98b 100644 --- a/audit-trail-rs/src/core/types/role_map.rs +++ b/audit-trail-rs/src/core/types/role_map.rs @@ -12,12 +12,12 @@ use iota_interaction::types::programmable_transaction_builder::ProgrammableTrans use iota_interaction::types::transaction::Argument; use iota_interaction::{MoveType, ident_str}; use serde::{Deserialize, Serialize}; +use serde_aux::field_attributes::deserialize_option_number_from_string; use super::permission::Permission; use crate::core::internal::move_collections::{deserialize_vec_map, deserialize_vec_set}; use crate::core::internal::tx; use crate::error::Error; -use serde_aux::field_attributes::deserialize_option_number_from_string; /// The role and capability registry attached to an audit trail. /// @@ -71,18 +71,16 @@ use serde_aux::field_attributes::deserialize_option_number_from_string; /// /// **Issuing** a capability requires the `capability_admin_permissions.add` permission. /// [`CapabilityIssueOptions`] allow restricting a newly minted capability further: -/// - `issued_to` — binds the capability to a specific wallet address; the Move runtime -/// rejects use by any other sender. -/// - `valid_from_ms` / `valid_until_ms` — a Unix-millisecond validity window; use outside -/// this range is rejected. +/// - `issued_to` — binds the capability to a specific wallet address; the Move runtime rejects use by any other sender. +/// - `valid_from_ms` / `valid_until_ms` — a Unix-millisecond validity window; use outside this range is rejected. /// /// **Revoking** a capability requires the `capability_admin_permissions.revoke` permission. /// Revocation adds the capability's ID to the [`revoked_capabilities`](RoleMap::revoked_capabilities) /// denylist; the object itself continues to exist on-chain but is refused by /// `assert_capability_valid`. The caller must provide: /// - the capability's object ID, and -/// - optionally its `valid_until` value, which allows the denylist entry to be cleaned up -/// automatically once it expires via [`AuditTrailHandle::access().cleanup_revoked_capabilities`]. +/// - optionally its `valid_until` value, which allows the denylist entry to be cleaned up automatically once it expires +/// via [`AuditTrailHandle::access().cleanup_revoked_capabilities`]. /// /// Because the `RoleMap` uses a denylist (not an allowlist), it does **not** track all /// issued capabilities on-chain. Callers are responsible for maintaining an off-chain @@ -103,8 +101,8 @@ use serde_aux::field_attributes::deserialize_option_number_from_string; /// /// Two invariants protect it from accidental lock-out: /// - The initial admin **role** can never be deleted. -/// - Updating its permissions is only permitted if the new permission set still includes all -/// configured role and capability admin permissions. +/// - Updating its permissions is only permitted if the new permission set still includes all configured role and +/// capability admin permissions. /// /// Initial admin **capabilities** are tracked separately in /// [`initial_admin_cap_ids`](RoleMap::initial_admin_cap_ids) and must be managed through @@ -128,7 +126,6 @@ use serde_aux::field_attributes::deserialize_option_number_from_string; /// Each role may optionally include a [`RoleTags`] allowlist that grants the holders of that /// role's capability access to records tagged with that specific tag. /// -/// #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RoleMap { /// The object ID of the audit trail this role map belongs to. diff --git a/bindings/wasm/audit_trail_wasm/cypress.config.ts b/bindings/wasm/audit_trail_wasm/cypress.config.ts index 1f1d784a..481c7412 100644 --- a/bindings/wasm/audit_trail_wasm/cypress.config.ts +++ b/bindings/wasm/audit_trail_wasm/cypress.config.ts @@ -30,4 +30,4 @@ export default defineConfig({ }); }, }, -}); \ No newline at end of file +}); diff --git a/bindings/wasm/audit_trail_wasm/cypress/app/src/audit_trail.ts b/bindings/wasm/audit_trail_wasm/cypress/app/src/audit_trail.ts index eb5544b9..d5bbf1f7 100644 --- a/bindings/wasm/audit_trail_wasm/cypress/app/src/audit_trail.ts +++ b/bindings/wasm/audit_trail_wasm/cypress/app/src/audit_trail.ts @@ -15,4 +15,4 @@ export const runTest = async (example: string) => { init(url) .then(() => { console.log("init"); - }); \ No newline at end of file + }); diff --git a/bindings/wasm/audit_trail_wasm/cypress/app/src/main.ts b/bindings/wasm/audit_trail_wasm/cypress/app/src/main.ts index c8b81570..e8945451 100644 --- a/bindings/wasm/audit_trail_wasm/cypress/app/src/main.ts +++ b/bindings/wasm/audit_trail_wasm/cypress/app/src/main.ts @@ -1,3 +1,3 @@ import { runTest } from "./audit_trail"; -globalThis.runTest = runTest; \ No newline at end of file +globalThis.runTest = runTest; diff --git a/bindings/wasm/audit_trail_wasm/cypress/app/src/vite-env.d.ts b/bindings/wasm/audit_trail_wasm/cypress/app/src/vite-env.d.ts index 151aa685..11f02fe2 100644 --- a/bindings/wasm/audit_trail_wasm/cypress/app/src/vite-env.d.ts +++ b/bindings/wasm/audit_trail_wasm/cypress/app/src/vite-env.d.ts @@ -1 +1 @@ -/// \ No newline at end of file +/// diff --git a/bindings/wasm/audit_trail_wasm/cypress/app/vite.config.js b/bindings/wasm/audit_trail_wasm/cypress/app/vite.config.js index d7721504..305644fb 100644 --- a/bindings/wasm/audit_trail_wasm/cypress/app/vite.config.js +++ b/bindings/wasm/audit_trail_wasm/cypress/app/vite.config.js @@ -35,4 +35,4 @@ export default defineConfig(({ command, mode }) => { }, }, }; -}); \ No newline at end of file +}); diff --git a/bindings/wasm/audit_trail_wasm/cypress/e2e/tests.cy.js b/bindings/wasm/audit_trail_wasm/cypress/e2e/tests.cy.js index e93d2d3c..883e2896 100644 --- a/bindings/wasm/audit_trail_wasm/cypress/e2e/tests.cy.js +++ b/bindings/wasm/audit_trail_wasm/cypress/e2e/tests.cy.js @@ -32,4 +32,4 @@ describe( }); }); }, -); \ No newline at end of file +); diff --git a/bindings/wasm/audit_trail_wasm/examples/README.md b/bindings/wasm/audit_trail_wasm/examples/README.md index 95a1d57a..338b8b42 100644 --- a/bindings/wasm/audit_trail_wasm/examples/README.md +++ b/bindings/wasm/audit_trail_wasm/examples/README.md @@ -35,28 +35,28 @@ Available examples: ### Core -| Name | Description | -| ------------------------------ | ------------------------------------------------------------------------------------------------- | -| `01_create_audit_trail` | Creates an audit trail, defines a RecordAdmin role, and issues a capability for it | -| `02_add_and_read_records` | Adds follow-up records, reads them individually and through paginated reads | -| `03_update_metadata` | Updates and clears mutable metadata while preserving immutable metadata via a MetadataAdmin role | -| `04_configure_locking` | Configures write and delete locks, demonstrates that locks block record creation | -| `05_manage_access` | Creates and updates a role, then demonstrates constrained capability issuance, revoke and destroy flows, denylist cleanup, and final role removal | -| `06_delete_records` | Deletes individual records and batch-deletes remaining records | -| `07_access_read_only_methods` | Reads trail metadata, record counts, pagination, and lock status | -| `08_delete_audit_trail` | Shows that non-empty trails cannot be deleted, batch-deletes records, then deletes the trail | +| Name | Description | +| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| `01_create_audit_trail` | Creates an audit trail, defines a RecordAdmin role, and issues a capability for it | +| `02_add_and_read_records` | Adds follow-up records, reads them individually and through paginated reads | +| `03_update_metadata` | Updates and clears mutable metadata while preserving immutable metadata via a MetadataAdmin role | +| `04_configure_locking` | Configures write and delete locks, demonstrates that locks block record creation | +| `05_manage_access` | Creates and updates a role, then demonstrates constrained capability issuance, revoke and destroy flows, denylist cleanup, and final role removal | +| `06_delete_records` | Deletes individual records and batch-deletes remaining records | +| `07_access_read_only_methods` | Reads trail metadata, record counts, pagination, and lock status | +| `08_delete_audit_trail` | Shows that non-empty trails cannot be deleted, batch-deletes records, then deletes the trail | ### Advanced -| Name | Description | -| ------------------------------ | --------------------------------------------------------------------------------------------- | -| `09_tagged_records` | Uses role tags and address-bound capabilities to restrict who may add tagged records | -| `10_capability_constraints` | Shows address-bound capability use and how revocation immediately blocks future writes | -| `11_manage_record_tags` | Delegates tag management, adds/removes tags, shows that in-use tags cannot be removed | +| Name | Description | +| --------------------------- | -------------------------------------------------------------------------------------- | +| `09_tagged_records` | Uses role tags and address-bound capabilities to restrict who may add tagged records | +| `10_capability_constraints` | Shows address-bound capability use and how revocation immediately blocks future writes | +| `11_manage_record_tags` | Delegates tag management, adds/removes tags, shows that in-use tags cannot be removed | ### 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 | diff --git a/bindings/wasm/audit_trail_wasm/examples/src/01_create_audit_trail.ts b/bindings/wasm/audit_trail_wasm/examples/src/01_create_audit_trail.ts index 745f0456..e780986a 100644 --- a/bindings/wasm/audit_trail_wasm/examples/src/01_create_audit_trail.ts +++ b/bindings/wasm/audit_trail_wasm/examples/src/01_create_audit_trail.ts @@ -41,4 +41,4 @@ export async function createAuditTrail(): Promise { const roleNames = onChain.roles.roles.map((r) => r.name); console.log("Roles:", roleNames); assert.ok(roleNames.includes("RecordAdmin")); -} \ No newline at end of file +} diff --git a/bindings/wasm/audit_trail_wasm/examples/src/02_add_and_read_records.ts b/bindings/wasm/audit_trail_wasm/examples/src/02_add_and_read_records.ts index 73222125..0858de8c 100644 --- a/bindings/wasm/audit_trail_wasm/examples/src/02_add_and_read_records.ts +++ b/bindings/wasm/audit_trail_wasm/examples/src/02_add_and_read_records.ts @@ -47,4 +47,4 @@ export async function addAndReadRecords(): Promise { assert.equal(firstPage.records.length, 2); assert.equal(firstPage.hasNextPage, true); assert.equal(secondPage.records.length, 1); -} \ No newline at end of file +} diff --git a/bindings/wasm/audit_trail_wasm/examples/src/03_update_metadata.ts b/bindings/wasm/audit_trail_wasm/examples/src/03_update_metadata.ts index 92977873..2935ba79 100644 --- a/bindings/wasm/audit_trail_wasm/examples/src/03_update_metadata.ts +++ b/bindings/wasm/audit_trail_wasm/examples/src/03_update_metadata.ts @@ -65,4 +65,4 @@ export async function updateMetadata(): Promise { assert.equal(afterClear.immutableMetadata?.name, "Shipment Processing"); assert.equal(afterClear.updatableMetadata, undefined); -} \ No newline at end of file +} diff --git a/bindings/wasm/audit_trail_wasm/examples/src/04_configure_locking.ts b/bindings/wasm/audit_trail_wasm/examples/src/04_configure_locking.ts index b555049b..bbeeb418 100644 --- a/bindings/wasm/audit_trail_wasm/examples/src/04_configure_locking.ts +++ b/bindings/wasm/audit_trail_wasm/examples/src/04_configure_locking.ts @@ -1,7 +1,14 @@ // Copyright 2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -import { CapabilityIssueOptions, Data, LockingConfig, LockingWindow, PermissionSet, TimeLock } from "@iota/audit-trail/node"; +import { + CapabilityIssueOptions, + Data, + LockingConfig, + LockingWindow, + PermissionSet, + TimeLock, +} from "@iota/audit-trail/node"; import { strict as assert } from "assert"; import { createTrailWithSeedRecord, getFundedClient, TEST_GAS_BUDGET } from "./util"; diff --git a/bindings/wasm/audit_trail_wasm/examples/src/07_access_read_only_methods.ts b/bindings/wasm/audit_trail_wasm/examples/src/07_access_read_only_methods.ts index d8626e93..44c7d6cb 100644 --- a/bindings/wasm/audit_trail_wasm/examples/src/07_access_read_only_methods.ts +++ b/bindings/wasm/audit_trail_wasm/examples/src/07_access_read_only_methods.ts @@ -1,7 +1,14 @@ // Copyright 2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -import { CapabilityIssueOptions, Data, LockingConfig, LockingWindow, PermissionSet, TimeLock } from "@iota/audit-trail/node"; +import { + CapabilityIssueOptions, + Data, + LockingConfig, + LockingWindow, + PermissionSet, + TimeLock, +} from "@iota/audit-trail/node"; import { strict as assert } from "assert"; import { getFundedClient, TEST_GAS_BUDGET } from "./util"; @@ -73,4 +80,4 @@ export async function accessReadOnlyMethods(): Promise { assert.equal(count, 2n); assert.equal(initialRecord.data.toString(), "Initial record"); assert.equal(firstPage.records.length, 2); -} \ No newline at end of file +} diff --git a/bindings/wasm/audit_trail_wasm/examples/src/08_delete_audit_trail.ts b/bindings/wasm/audit_trail_wasm/examples/src/08_delete_audit_trail.ts index 4c8a4c25..fb9003f2 100644 --- a/bindings/wasm/audit_trail_wasm/examples/src/08_delete_audit_trail.ts +++ b/bindings/wasm/audit_trail_wasm/examples/src/08_delete_audit_trail.ts @@ -75,4 +75,4 @@ export async function deleteAuditTrail(): Promise { // Expected } assert.equal(getAfterDeleteSucceeded, false, "deleted trail should no longer be readable"); -} \ No newline at end of file +} diff --git a/bindings/wasm/audit_trail_wasm/examples/src/advanced/09_tagged_records.ts b/bindings/wasm/audit_trail_wasm/examples/src/advanced/09_tagged_records.ts index f11ec4d3..6b35c75c 100644 --- a/bindings/wasm/audit_trail_wasm/examples/src/advanced/09_tagged_records.ts +++ b/bindings/wasm/audit_trail_wasm/examples/src/advanced/09_tagged_records.ts @@ -40,7 +40,13 @@ export async function taggedRecords(): Promise { .withGasBudget(TEST_GAS_BUDGET) .buildAndExecute(admin); - console.log("Issued FinanceWriter capability", issued.output.capabilityId, "to", financeWriter.senderAddress(), "\n"); + console.log( + "Issued FinanceWriter capability", + issued.output.capabilityId, + "to", + financeWriter.senderAddress(), + "\n", + ); const financeRecords = financeWriter.trail(trailId).records(); @@ -50,7 +56,7 @@ export async function taggedRecords(): Promise { .withGasBudget(TEST_GAS_BUDGET) .buildAndExecute(financeWriter); - console.log("Added tagged record at sequence number", added.output.sequenceNumber, 'with tag "finance".\n'); + console.log("Added tagged record at sequence number", added.output.sequenceNumber, "with tag \"finance\".\n"); // Attempt to add a record with a different tag — should fail let wrongTagSucceeded = false; diff --git a/bindings/wasm/audit_trail_wasm/examples/src/advanced/10_capability_constraints.ts b/bindings/wasm/audit_trail_wasm/examples/src/advanced/10_capability_constraints.ts index a9ce38ed..47c8df7d 100644 --- a/bindings/wasm/audit_trail_wasm/examples/src/advanced/10_capability_constraints.ts +++ b/bindings/wasm/audit_trail_wasm/examples/src/advanced/10_capability_constraints.ts @@ -90,4 +90,4 @@ export async function capabilityConstraints(): Promise { assert.equal(revokedSucceeded, false, "revoked capabilities must no longer authorize record writes"); console.log("Revoked capability", issued.output.capabilityId, "and verified it can no longer be used."); -} \ No newline at end of file +} diff --git a/bindings/wasm/audit_trail_wasm/examples/src/advanced/11_manage_record_tags.ts b/bindings/wasm/audit_trail_wasm/examples/src/advanced/11_manage_record_tags.ts index 583d6a98..beb05745 100644 --- a/bindings/wasm/audit_trail_wasm/examples/src/advanced/11_manage_record_tags.ts +++ b/bindings/wasm/audit_trail_wasm/examples/src/advanced/11_manage_record_tags.ts @@ -42,7 +42,7 @@ export async function manageRecordTags(): Promise { await trailHandle.tags().add("legal").withGasBudget(TEST_GAS_BUDGET).buildAndExecute(client); let onChain = await trailHandle.get(); - console.log('Registry after adding "legal":', onChain.tags.map((t) => t.tag), "\n"); + console.log("Registry after adding \"legal\":", onChain.tags.map((t) => t.tag), "\n"); assert.ok(onChain.tags.some((t) => t.tag === "finance")); assert.ok(onChain.tags.some((t) => t.tag === "legal")); @@ -81,9 +81,9 @@ export async function manageRecordTags(): Promise { await trailHandle.tags().remove("legal").withGasBudget(TEST_GAS_BUDGET).buildAndExecute(client); onChain = await trailHandle.get(); - console.log('Registry after removing "legal":', onChain.tags.map((t) => t.tag), "\n"); + console.log("Registry after removing \"legal\":", onChain.tags.map((t) => t.tag), "\n"); assert.ok(onChain.tags.some((t) => t.tag === "finance"), "finance tag should still exist"); assert.ok(!onChain.tags.some((t) => t.tag === "legal"), "legal tag should be removed"); console.log("Tag management completed successfully."); -} \ No newline at end of file +} diff --git a/bindings/wasm/audit_trail_wasm/examples/src/main.ts b/bindings/wasm/audit_trail_wasm/examples/src/main.ts index 955b0ef9..db36f304 100644 --- a/bindings/wasm/audit_trail_wasm/examples/src/main.ts +++ b/bindings/wasm/audit_trail_wasm/examples/src/main.ts @@ -55,4 +55,4 @@ export async function main(example?: string) { main().catch((error) => { console.error("Example error:", error); -}); \ No newline at end of file +}); 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 959940f0..5e2a7733 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 @@ -107,7 +107,11 @@ export async function customsClearance(): Promise { const exportFiled = await client .trail(trailId) .records() - .add(Data.fromString("Export declaration filed with German customs"), "event:export_declaration_filed", "export") + .add( + Data.fromString("Export declaration filed with German customs"), + "event:export_declaration_filed", + "export", + ) .withGasBudget(TEST_GAS_BUDGET) .buildAndExecute(client); @@ -138,14 +142,22 @@ export async function customsClearance(): Promise { await client .trail(trailId) .records() - .add(Data.fromString("Import broker attempted to record an inspection result"), "event:invalid_inspection_write", "inspection") + .add( + Data.fromString("Import broker attempted to record an inspection result"), + "event:invalid_inspection_write", + "inspection", + ) .withGasBudget(TEST_GAS_BUDGET) .buildAndExecute(client); inspectionDenied = true; } catch { // Expected } - assert.equal(inspectionDenied, false, "inspection-tagged writes should fail before an inspection-scoped capability exists"); + assert.equal( + inspectionDenied, + false, + "inspection-tagged writes should fail before an inspection-scoped capability exists", + ); console.log("Inspection write was correctly denied before the inspector role existed.\n"); // 8. Create inspector role and add inspection record @@ -154,7 +166,11 @@ export async function customsClearance(): Promise { const inspectionDone = await client .trail(trailId) .records() - .add(Data.fromString("Customs inspection completed with no discrepancies"), "event:inspection_completed", "inspection") + .add( + Data.fromString("Customs inspection completed with no discrepancies"), + "event:inspection_completed", + "inspection", + ) .withGasBudget(TEST_GAS_BUDGET) .buildAndExecute(client); console.log("Inspector added record", inspectionDone.output.sequenceNumber + ".\n"); 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 c41e6a33..4cfa888e 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 @@ -39,12 +39,19 @@ export async function clinicalTrial(): Promise { const { output: created } = await client .createTrail() .withRecordTags(["enrollment", "safety", "efficacy"]) - .withTrailMetadata("Protocol CTR-2026-03742", "Phase III: Efficacy of Drug X vs Placebo in Moderate-to-Severe Asthma") + .withTrailMetadata( + "Protocol CTR-2026-03742", + "Phase III: Efficacy of Drug X vs Placebo in Moderate-to-Severe Asthma", + ) .withUpdatableMetadata("Phase: Enrollment") .withLockingConfig( new LockingConfig(LockingWindow.withCountBased(BigInt(3)), TimeLock.withNone(), TimeLock.withNone()), ) - .withInitialRecordString("Clinical trial CTR-2026-03742 opened for enrollment", "event:trial_opened", "enrollment") + .withInitialRecordString( + "Clinical trial CTR-2026-03742 opened for enrollment", + "event:trial_opened", + "enrollment", + ) .finish() .withGasBudget(TEST_GAS_BUDGET) .buildAndExecute(client); @@ -121,7 +128,11 @@ export async function clinicalTrial(): Promise { const efficacyRecord = await client .trail(trailId) .records() - .add(Data.fromString("Week 12: FEV1 improvement of 320 mL over baseline for P-101"), "event:efficacy_observed", "efficacy") + .add( + Data.fromString("Week 12: FEV1 improvement of 320 mL over baseline for P-101"), + "event:efficacy_observed", + "efficacy", + ) .withGasBudget(TEST_GAS_BUDGET) .buildAndExecute(client); @@ -141,7 +152,7 @@ export async function clinicalTrial(): Promise { .add("pk") .withGasBudget(TEST_GAS_BUDGET) .buildAndExecute(client); - console.log('Added tag "pk" (pharmacokinetics) to the trail.'); + console.log("Added tag \"pk\" (pharmacokinetics) to the trail."); await issueTaggedRecordRole(client, trailId, "PkAnalyst", "pk"); @@ -173,7 +184,11 @@ export async function clinicalTrial(): Promise { false, "recent records must be protected by the count-based deletion window", ); - console.log("Record", pkRecord.output.sequenceNumber, "is within the deletion window (newest 3) and cannot be deleted.\n"); + console.log( + "Record", + pkRecord.output.sequenceNumber, + "is within the deletion window (newest 3) and cannot be deleted.\n", + ); // 7. Monitor updates study phase metadata console.log("--- Metadata Update ---"); @@ -210,7 +225,11 @@ export async function clinicalTrial(): Promise { .buildAndExecute(client); const finalLocking = await client.trail(trailId).get(); - console.log("Delete-trail lock set to", finalLocking.lockingConfig.deleteTrailLock.type, "— trail cannot be deleted.\n"); + console.log( + "Delete-trail lock set to", + finalLocking.lockingConfig.deleteTrailLock.type, + "— trail cannot be deleted.\n", + ); // 9. Regulator read-only verification console.log("--- Regulator Verification ---"); diff --git a/bindings/wasm/audit_trail_wasm/examples/src/tests.ts b/bindings/wasm/audit_trail_wasm/examples/src/tests.ts index 7d665ee0..7848a8ef 100644 --- a/bindings/wasm/audit_trail_wasm/examples/src/tests.ts +++ b/bindings/wasm/audit_trail_wasm/examples/src/tests.ts @@ -17,7 +17,7 @@ import { manageRecordTags } from "./advanced/11_manage_record_tags"; import { customsClearance } from "./real-world/01_customs_clearance"; import { clinicalTrial } from "./real-world/02_clinical_trial"; -describe("Audit trail wasm node examples", function () { +describe("Audit trail wasm node examples", function() { afterEach(() => { console.log("\n----------------------------------------------------\n"); }); @@ -61,4 +61,4 @@ describe("Audit trail wasm node examples", function () { it("runs clinical trial example", async () => { await clinicalTrial(); }); -}); \ No newline at end of file +}); 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 1a7bb9d8..1555454d 100644 --- a/bindings/wasm/audit_trail_wasm/examples/src/web-main.ts +++ b/bindings/wasm/audit_trail_wasm/examples/src/web-main.ts @@ -51,4 +51,4 @@ export async function main(example?: string) { default: throw new Error(`Unknown example name: '${argument}'`); } -} \ No newline at end of file +} diff --git a/examples/audit-trail/README.md b/examples/audit-trail/README.md index 433bad13..23a0be5a 100644 --- a/examples/audit-trail/README.md +++ b/examples/audit-trail/README.md @@ -19,11 +19,11 @@ In case of running the examples against an existing network, this network needs You'll need one or more of the following environment variables depending on your setup: -| Name | Required for local node | Required for testnet | Required for other node | -| -------------------------- | :---------------------: | :------------------: | :---------------------: | -| IOTA_AUDIT_TRAIL_PKG_ID | x | x | x | -| IOTA_TF_COMPONENTS_PKG_ID | x | | | -| API_ENDPOINT | | x | x | +| Name | Required for local node | Required for testnet | Required for other node | +| ------------------------- | :---------------------: | :------------------: | :---------------------: | +| IOTA_AUDIT_TRAIL_PKG_ID | x | x | x | +| IOTA_TF_COMPONENTS_PKG_ID | x | | | +| API_ENDPOINT | | x | x | > **Note:** On localnet both `IOTA_AUDIT_TRAIL_PKG_ID` and `IOTA_TF_COMPONENTS_PKG_ID` resolve to the same package ID because the TfComponents dependency is published together with the audit trail package. @@ -56,31 +56,31 @@ IOTA_AUDIT_TRAIL_PKG_ID=0x... IOTA_TF_COMPONENTS_PKG_ID=0x... cargo run --releas ## Examples -| Name | Information | -| :-------------------------------------------------------------------------------------------------------------------------------------- | :-------------------------------------------------------------------------------------------------------------- | -| [01_create_audit_trail](https://github.com/iotaledger/notarization/tree/main/examples/audit-trail/01_create_audit_trail.rs) | Creates an audit trail, defines a `RecordAdmin` role using the Admin capability, and issues a capability for it. | -| [02_add_and_read_records](https://github.com/iotaledger/notarization/tree/main/examples/audit-trail/02_add_and_read_records.rs) | Adds follow-up records to a trail, then loads them back individually and through paginated reads. | -| [03_update_metadata](https://github.com/iotaledger/notarization/tree/main/examples/audit-trail/03_update_metadata.rs) | Updates and clears the trail's mutable metadata while preserving immutable metadata. | -| [04_configure_locking](https://github.com/iotaledger/notarization/tree/main/examples/audit-trail/04_configure_locking.rs) | Configures write and delete locks, then shows how those rules affect record creation. | -| [05_manage_access](https://github.com/iotaledger/notarization/tree/main/examples/audit-trail/05_manage_access.rs) | Creates and updates a role, then demonstrates constrained capability issuance, revoke and destroy flows, denylist cleanup, and final role removal. | -| [06_delete_records](https://github.com/iotaledger/notarization/tree/main/examples/audit-trail/06_delete_records.rs) | Deletes an individual record and then removes the remaining records in a batch. | -| [07_access_read_only_methods](https://github.com/iotaledger/notarization/tree/main/examples/audit-trail/07_access_read_only_methods.rs) | Reads back trail metadata, locking state, record counts, and paginated record data. | -| [08_delete_audit_trail](https://github.com/iotaledger/notarization/tree/main/examples/audit-trail/08_delete_audit_trail.rs) | Empties a trail and then deletes it, showing that non-empty trails cannot be removed. | +| Name | Information | +| :-------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------- | +| [01_create_audit_trail](https://github.com/iotaledger/notarization/tree/main/examples/audit-trail/01_create_audit_trail.rs) | Creates an audit trail, defines a `RecordAdmin` role using the Admin capability, and issues a capability for it. | +| [02_add_and_read_records](https://github.com/iotaledger/notarization/tree/main/examples/audit-trail/02_add_and_read_records.rs) | Adds follow-up records to a trail, then loads them back individually and through paginated reads. | +| [03_update_metadata](https://github.com/iotaledger/notarization/tree/main/examples/audit-trail/03_update_metadata.rs) | Updates and clears the trail's mutable metadata while preserving immutable metadata. | +| [04_configure_locking](https://github.com/iotaledger/notarization/tree/main/examples/audit-trail/04_configure_locking.rs) | Configures write and delete locks, then shows how those rules affect record creation. | +| [05_manage_access](https://github.com/iotaledger/notarization/tree/main/examples/audit-trail/05_manage_access.rs) | Creates and updates a role, then demonstrates constrained capability issuance, revoke and destroy flows, denylist cleanup, and final role removal. | +| [06_delete_records](https://github.com/iotaledger/notarization/tree/main/examples/audit-trail/06_delete_records.rs) | Deletes an individual record and then removes the remaining records in a batch. | +| [07_access_read_only_methods](https://github.com/iotaledger/notarization/tree/main/examples/audit-trail/07_access_read_only_methods.rs) | Reads back trail metadata, locking state, record counts, and paginated record data. | +| [08_delete_audit_trail](https://github.com/iotaledger/notarization/tree/main/examples/audit-trail/08_delete_audit_trail.rs) | Empties a trail and then deletes it, showing that non-empty trails cannot be removed. | ## Advanced Examples -| Name | Information | -| :-------------------------------------------------------------------------------------------------------------------------------------- | :-------------------------------------------------------------------------------------------------------------- | -| [09_tagged_records](https://github.com/iotaledger/notarization/tree/main/examples/audit-trail/advanced/09_tagged_records.rs) | Uses role tags and address-bound capabilities to restrict who may add tagged records. | +| Name | Information | +| :------------------------------------------------------------------------------------------------------------------------------------------- | :-------------------------------------------------------------------------------------- | +| [09_tagged_records](https://github.com/iotaledger/notarization/tree/main/examples/audit-trail/advanced/09_tagged_records.rs) | Uses role tags and address-bound capabilities to restrict who may add tagged records. | | [10_capability_constraints](https://github.com/iotaledger/notarization/tree/main/examples/audit-trail/advanced/10_capability_constraints.rs) | Shows address-bound capability use and how revocation immediately blocks future writes. | -| [11_manage_record_tags](https://github.com/iotaledger/notarization/tree/main/examples/audit-trail/advanced/11_manage_record_tags.rs) | Delegates record-tag administration and shows that in-use tags cannot be removed. | +| [11_manage_record_tags](https://github.com/iotaledger/notarization/tree/main/examples/audit-trail/advanced/11_manage_record_tags.rs) | Delegates record-tag administration and shows that in-use tags cannot be removed. | ## 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. | ## Key Concepts @@ -106,14 +106,14 @@ Access to trail operations is controlled via roles and capabilities: `PermissionSet` convenience constructors cover common role configurations: -| Constructor | Permissions granted | -| :-------------------------------- | :------------------------------------------------------------------------------- | -| `admin_permissions()` | AddRoles, UpdateRoles, DeleteRoles, AddCapabilities, RevokeCapabilities, AddRecordTags, DeleteRecordTags | -| `record_admin_permissions()` | AddRecord, DeleteRecord, CorrectRecord | -| `locking_admin_permissions()` | UpdateLockingConfig (and all sub-variants) | -| `cap_admin_permissions()` | AddCapabilities, RevokeCapabilities | -| `tag_admin_permissions()` | AddRecordTags, DeleteRecordTags | -| `metadata_admin_permissions()` | UpdateMetadata, DeleteMetadata | +| Constructor | Permissions granted | +| :----------------------------- | :------------------------------------------------------------------------------------------------------- | +| `admin_permissions()` | AddRoles, UpdateRoles, DeleteRoles, AddCapabilities, RevokeCapabilities, AddRecordTags, DeleteRecordTags | +| `record_admin_permissions()` | AddRecord, DeleteRecord, CorrectRecord | +| `locking_admin_permissions()` | UpdateLockingConfig (and all sub-variants) | +| `cap_admin_permissions()` | AddCapabilities, RevokeCapabilities | +| `tag_admin_permissions()` | AddRecordTags, DeleteRecordTags | +| `metadata_admin_permissions()` | UpdateMetadata, DeleteMetadata | ### Capability Constraints diff --git a/examples/audit-trail/real-world/02_clinical_trial.rs b/examples/audit-trail/real-world/02_clinical_trial.rs index e1931291..b0c05cff 100644 --- a/examples/audit-trail/real-world/02_clinical_trial.rs +++ b/examples/audit-trail/real-world/02_clinical_trial.rs @@ -13,8 +13,8 @@ //! - record tags: `enrollment`, `safety`, `efficacy`, `pk` (added mid-study) //! - roles and capabilities: each role writes only its designated tag //! - time-constrained capabilities: Monitor access is windowed to the study period -//! - locking: a deletion window protects recent records; a time-lock freezes the -//! dataset after the Data Safety Board completes its review +//! - locking: a deletion window protects recent records; a time-lock freezes the dataset after the Data Safety Board +//! completes its review //! - read-only verification: a regulator inspects the trail without write access use anyhow::{Result, ensure}; diff --git a/examples/notarization/README.md b/examples/notarization/README.md index c82dfdef..d1264fca 100644 --- a/examples/notarization/README.md +++ b/examples/notarization/README.md @@ -42,8 +42,8 @@ IOTA_NOTARIZATION_PKG_ID=0x... cargo run --release --example 01_create_locked_no The following basic CRUD (Create, Read, Update, Delete) examples are available: -| Name | Information | -| :------------------------------------------------------------------------------------------------------------------------------------ | :-------------------------------------------------------------------------------- | +| Name | Information | +| :------------------------------------------------------------------------------------------------------------------------------------------------- | :-------------------------------------------------------------------------------- | | [01_create_locked_notarization](https://github.com/iotaledger/notarization/tree/main/examples/notarization/01_create_locked_notarization.rs) | Demonstrates how to create a locked notarization with delete locks. | | [02_create_dynamic_notarization](https://github.com/iotaledger/notarization/tree/main/examples/notarization/02_create_dynamic_notarization.rs) | Demonstrates how to create dynamic notarizations with and without transfer locks. | | [03_update_dynamic_notarization](https://github.com/iotaledger/notarization/tree/main/examples/notarization/03_update_dynamic_notarization.rs) | Demonstrates that dynamic notarizations can be updated | @@ -57,8 +57,8 @@ The following basic CRUD (Create, Read, Update, Delete) examples are available: The following examples demonstrate practical use cases with proper field usage: -| Name | Information | -| :--------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------------------------- | +| Name | Information | +| :---------------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------------------------- | | [iot_weather_station](https://github.com/iotaledger/notarization/tree/main/examples/notarization/real-world/iot_weather_station.rs) | IoT weather station using dynamic notarization for continuous sensor data updates. | | [legal_contract](https://github.com/iotaledger/notarization/tree/main/examples/notarization/real-world/legal_contract.rs) | Legal contract using locked notarization for immutable document hash attestation. | From ed5a5af4a0c8b57462a6865b69f00a41a4d6e3ad Mon Sep 17 00:00:00 2001 From: Yasir Date: Wed, 8 Apr 2026 16:22:55 +0300 Subject: [PATCH 148/189] fix: address audit trail wasm review issues --- .github/workflows/build-and-test.yml | 10 ++++++---- .github/workflows/upload-docs.yml | 3 +++ .../cypress/app/src/vite-env.d.ts | 11 +++++++++++ .../src/real-world/01_customs_clearance.ts | 3 ++- .../examples/src/real-world/02_clinical_trial.ts | 3 ++- .../audit_trail_wasm/src/trail_handle/mod.rs | 1 + .../audit_trail_wasm/src/trail_handle/records.rs | 12 +----------- .../audit_trail_wasm/src/trail_handle/tags.rs | 16 +++++++++++++++- 8 files changed, 41 insertions(+), 18 deletions(-) diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index e2035e39..657bdcdf 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -17,11 +17,13 @@ on: - ".github/workflows/shared-build-wasm.yml" - ".github/actions/**" - "**.rs" + - "**.move" - "**.toml" - "**.lock" - "bindings/**" - "!bindings/**.md" - "bindings/wasm/notarization_wasm/README.md" # the Readme contain txm tests + - "bindings/wasm/audit_trail_wasm/README.md" schedule: # * is a special character in YAML so you have to quote this string @@ -239,7 +241,7 @@ jobs: wasm-crate-name: audit_trail_wasm test-wasm-notarization: - needs: build-wasm-notarization + needs: [build-wasm-notarization, check-for-run-condition] if: ${{ needs.check-for-run-condition.outputs.should-run == 'true' }} runs-on: ubuntu-24.04 strategy: @@ -281,7 +283,7 @@ jobs: working-directory: bindings/wasm/notarization_wasm test-wasm-audit-trail: - needs: build-wasm-audit-trail + needs: [build-wasm-audit-trail, check-for-run-condition] if: ${{ needs.check-for-run-condition.outputs.should-run == 'true' }} runs-on: ubuntu-24.04 @@ -319,7 +321,7 @@ jobs: run: npm run test:node working-directory: bindings/wasm/audit_trail_wasm test-wasm-browser-notarization: - needs: build-wasm-notarization + needs: [build-wasm-notarization, check-for-run-condition] if: ${{ needs.check-for-run-condition.outputs.should-run == 'true' }} runs-on: ubuntu-24.04 strategy: @@ -370,7 +372,7 @@ jobs: run: docker run --network host cypress-test test:browser:${{ matrix.browser }} test-wasm-browser-audit-trail: - needs: build-wasm-audit-trail + needs: [build-wasm-audit-trail, check-for-run-condition] if: ${{ needs.check-for-run-condition.outputs.should-run == 'true' }} runs-on: ubuntu-24.04 strategy: diff --git a/.github/workflows/upload-docs.yml b/.github/workflows/upload-docs.yml index d6d95673..5c2dda6e 100644 --- a/.github/workflows/upload-docs.yml +++ b/.github/workflows/upload-docs.yml @@ -8,6 +8,9 @@ on: version: description: "Version to publish docs under (e.g. `v1.2.3-dev.1`)" required: true + ref: + description: "Optional git ref to checkout before building docs" + required: false env: GH_TOKEN: ${{ github.token }} diff --git a/bindings/wasm/audit_trail_wasm/cypress/app/src/vite-env.d.ts b/bindings/wasm/audit_trail_wasm/cypress/app/src/vite-env.d.ts index 11f02fe2..01691ca6 100644 --- a/bindings/wasm/audit_trail_wasm/cypress/app/src/vite-env.d.ts +++ b/bindings/wasm/audit_trail_wasm/cypress/app/src/vite-env.d.ts @@ -1 +1,12 @@ /// +declare const IOTA_AUDIT_TRAIL_PKG_ID: string; +declare const IOTA_TF_COMPONENTS_PKG_ID: string; +declare const NETWORK_NAME_FAUCET: string; +declare const ENV_NETWORK_URL: string; +declare const runTest: (example: string) => Promise; + +declare global { + var runTest: (example: string) => Promise; +} + +export {}; 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 5e2a7733..0b8a4f7f 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 @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { + AuditTrailClient, CapabilityIssueOptions, Data, LockingConfig, @@ -246,7 +247,7 @@ export async function customsClearance(): Promise { } async function issueTaggedRecordRole( - client: any, + client: AuditTrailClient, trailId: string, roleName: string, tag: string, 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 4cfa888e..5357798a 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 @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { + AuditTrailClient, CapabilityIssueOptions, Data, LockingConfig, @@ -260,7 +261,7 @@ export async function clinicalTrial(): Promise { } async function issueTaggedRecordRole( - client: any, + client: AuditTrailClient, trailId: string, roleName: string, tag: string, diff --git a/bindings/wasm/audit_trail_wasm/src/trail_handle/mod.rs b/bindings/wasm/audit_trail_wasm/src/trail_handle/mod.rs index 2711433b..7a71477a 100644 --- a/bindings/wasm/audit_trail_wasm/src/trail_handle/mod.rs +++ b/bindings/wasm/audit_trail_wasm/src/trail_handle/mod.rs @@ -116,6 +116,7 @@ impl WasmAuditTrailHandle { pub fn tags(&self) -> WasmTrailTags { WasmTrailTags { + read_only: self.read_only.clone(), full: self.full.clone(), trail_id: self.trail_id, } diff --git a/bindings/wasm/audit_trail_wasm/src/trail_handle/records.rs b/bindings/wasm/audit_trail_wasm/src/trail_handle/records.rs index 68456e5e..03d67538 100644 --- a/bindings/wasm/audit_trail_wasm/src/trail_handle/records.rs +++ b/bindings/wasm/audit_trail_wasm/src/trail_handle/records.rs @@ -12,7 +12,7 @@ use product_common::bindings::utils::into_transaction_builder; use wasm_bindgen::prelude::*; use crate::trail::{WasmAddRecord, WasmDeleteRecord, WasmDeleteRecordsBatch}; -use crate::types::{WasmData, WasmEmpty, WasmPaginatedRecord, WasmRecord}; +use crate::types::{WasmData, WasmPaginatedRecord, WasmRecord}; #[derive(Clone)] #[wasm_bindgen(js_name = TrailRecords, inspectable)] @@ -99,16 +99,6 @@ impl WasmTrailRecords { Ok(page.into()) } - pub async fn correct(&self, replaces: Vec, data: WasmData, metadata: Option) -> Result { - self.require_write()? - .trail(self.trail_id) - .records() - .correct(replaces, data.into(), metadata) - .await - .wasm_result()?; - Ok(WasmEmpty) - } - #[wasm_bindgen(unchecked_return_type = "TransactionBuilder")] pub fn add(&self, data: WasmData, metadata: Option, tag: Option) -> Result { let tx = self diff --git a/bindings/wasm/audit_trail_wasm/src/trail_handle/tags.rs b/bindings/wasm/audit_trail_wasm/src/trail_handle/tags.rs index 9e0e3766..65da9ce0 100644 --- a/bindings/wasm/audit_trail_wasm/src/trail_handle/tags.rs +++ b/bindings/wasm/audit_trail_wasm/src/trail_handle/tags.rs @@ -3,18 +3,21 @@ use anyhow::anyhow; use audit_trail::AuditTrailClient; +use audit_trail::AuditTrailClientReadOnly; use iota_interaction::types::base_types::ObjectID; use iota_interaction_ts::bindings::WasmTransactionSigner; -use iota_interaction_ts::wasm_error::{wasm_error, Result}; +use iota_interaction_ts::wasm_error::{wasm_error, Result, WasmResult}; use product_common::bindings::transaction::WasmTransactionBuilder; use product_common::bindings::utils::into_transaction_builder; use wasm_bindgen::prelude::*; use crate::trail::{WasmAddRecordTag, WasmRemoveRecordTag}; +use crate::types::WasmRecordTagEntry; #[derive(Clone)] #[wasm_bindgen(js_name = TrailTags, inspectable)] pub struct WasmTrailTags { + pub(crate) read_only: AuditTrailClientReadOnly, pub(crate) full: Option>, pub(crate) trail_id: ObjectID, } @@ -31,6 +34,17 @@ impl WasmTrailTags { #[wasm_bindgen(js_class = TrailTags)] impl WasmTrailTags { + pub async fn list(&self) -> Result> { + let trail = self.read_only.trail(self.trail_id).get().await.wasm_result()?; + let mut tags: Vec = trail + .tags + .iter() + .map(|(tag, usage_count)| (tag.clone(), *usage_count).into()) + .collect(); + tags.sort_unstable_by(|left, right| left.tag.cmp(&right.tag)); + Ok(tags) + } + #[wasm_bindgen(unchecked_return_type = "TransactionBuilder")] pub fn add(&self, tag: String) -> Result { let tx = self.require_write()?.trail(self.trail_id).tags().add(tag).into_inner(); From 09c617a5b23e21f17a00017cc00e59750b8caf88 Mon Sep 17 00:00:00 2001 From: Yasir Date: Thu, 9 Apr 2026 11:38:45 +0300 Subject: [PATCH 149/189] fix: update audit trail wasm browser setup --- audit-trail-rs/src/core/types/role_map.rs | 1 - .../wasm/audit_trail_wasm/package-lock.json | 2477 ++++++++++++++++- 2 files changed, 2336 insertions(+), 142 deletions(-) diff --git a/audit-trail-rs/src/core/types/role_map.rs b/audit-trail-rs/src/core/types/role_map.rs index f0c9e98b..fb3d9cc3 100644 --- a/audit-trail-rs/src/core/types/role_map.rs +++ b/audit-trail-rs/src/core/types/role_map.rs @@ -125,7 +125,6 @@ use crate::error::Error; /// /// Each role may optionally include a [`RoleTags`] allowlist that grants the holders of that /// role's capability access to records tagged with that specific tag. -/// #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RoleMap { /// The object ID of the audit trail this role map belongs to. diff --git a/bindings/wasm/audit_trail_wasm/package-lock.json b/bindings/wasm/audit_trail_wasm/package-lock.json index 3253e8af..6311fa79 100644 --- a/bindings/wasm/audit_trail_wasm/package-lock.json +++ b/bindings/wasm/audit_trail_wasm/package-lock.json @@ -14,9 +14,11 @@ "devDependencies": { "@types/mocha": "^9.1.0", "@types/node": "^22.0.0", + "cypress": "^14.2.0", "dprint": "^0.33.0", "mocha": "^9.2.0", "rimraf": "^6.0.1", + "start-server-and-test": "^2.0.11", "ts-mocha": "^9.0.2", "ts-node": "^10.9.2", "tsconfig-paths": "^4.1.0", @@ -75,6 +77,57 @@ "node": ">=12" } }, + "node_modules/@cypress/request": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@cypress/request/-/request-3.0.10.tgz", + "integrity": "sha512-hauBrOdvu08vOsagkZ/Aju5XuiZx6ldsLfByg1htFeldhex+PeMrYauANzFsMJeAA0+dyPLbDoX2OYuvVoLDkQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "aws-sign2": "~0.7.0", + "aws4": "^1.8.0", + "caseless": "~0.12.0", + "combined-stream": "~1.0.6", + "extend": "~3.0.2", + "forever-agent": "~0.6.1", + "form-data": "~4.0.4", + "http-signature": "~1.4.0", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.19", + "performance-now": "^2.1.0", + "qs": "~6.14.1", + "safe-buffer": "^5.1.2", + "tough-cookie": "^5.0.0", + "tunnel-agent": "^0.6.0", + "uuid": "^8.3.2" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@cypress/xvfb": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@cypress/xvfb/-/xvfb-1.2.4.tgz", + "integrity": "sha512-skbBzPggOVYCbnGgV+0dmBdW/s77ZkAOXIC1knS8NagwDjBrNC1LuXtQJeiN6l+m7lzmHtaoUw/ctJKdqkG57Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.1.0", + "lodash.once": "^4.1.1" + } + }, + "node_modules/@cypress/xvfb/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, "node_modules/@gerrit0/mini-shiki": { "version": "3.23.0", "resolved": "https://registry.npmjs.org/@gerrit0/mini-shiki/-/mini-shiki-3.23.0.tgz", @@ -140,6 +193,60 @@ "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, + "node_modules/@hapi/address": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz", + "integrity": "sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@hapi/formula": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@hapi/formula/-/formula-3.0.2.tgz", + "integrity": "sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/hoek": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.7.tgz", + "integrity": "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/pinpoint": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@hapi/pinpoint/-/pinpoint-2.0.1.tgz", + "integrity": "sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/tlds": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@hapi/tlds/-/tlds-1.1.6.tgz", + "integrity": "sha512-xdi7A/4NZokvV0ewovme3aUO5kQhW9pQ2YD1hRqZGhhSi5rBv4usHYidVocXSi9eihYsznZxLtAiEYYUL6VBGw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@hapi/topo": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-6.0.2.tgz", + "integrity": "sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + } + }, "node_modules/@iota/bcs": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@iota/bcs/-/bcs-1.5.0.tgz", @@ -330,6 +437,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tsconfig/node10": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", @@ -393,6 +507,20 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/sinonjs__fake-timers": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.1.tgz", + "integrity": "sha512-0kSuKjAS0TrGLJ0M/+8MaFkGsQhZpB6pxOmvS3K8FYI72K//YmdfoW9X2qPsAKh1mkwxGD5zib9s1FIFed6E8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/sizzle": { + "version": "2.3.10", + "resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.10.tgz", + "integrity": "sha512-TC0dmN0K8YcWEAEfiPi5gJP14eJe30TTGjkvek3iM/1NdHHsdCA/Td6GvNndMOo/iSnIsZ4HuuhrYPDAmbxzww==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -400,6 +528,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@ungap/promise-all-settled": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@ungap/promise-all-settled/-/promise-all-settled-1.1.2.tgz", @@ -433,6 +572,20 @@ "node": ">=0.4.0" } }, + "node_modules/aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-colors": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz", @@ -443,6 +596,35 @@ "node": ">=6" } }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-escapes/node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -483,6 +665,27 @@ "node": ">= 8" } }, + "node_modules/arch": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/arch/-/arch-2.2.0.tgz", + "integrity": "sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", @@ -507,6 +710,99 @@ "node": ">=0.10.0" } }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/aws-sign2": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", + "integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "*" + } + }, + "node_modules/aws4": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", + "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", + "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/axios/node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -514,6 +810,37 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, "node_modules/bignumber.js": { "version": "9.3.1", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", @@ -537,6 +864,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/blob-util": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/blob-util/-/blob-util-2.0.2.tgz", + "integrity": "sha512-T7JQa+zsXXEa6/8ZhHcQEW1UFfVM49Ts65uBkFL6fz2QmrElqmbajIDJvuA0tEhRe5eIjpV9ZF+0RfZR9voJFQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "dev": true, + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -568,6 +909,31 @@ "dev": true, "license": "ISC" }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/buffer-crc32": { "version": "0.2.13", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", @@ -585,6 +951,47 @@ "dev": true, "license": "MIT" }, + "node_modules/cachedir": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/cachedir/-/cachedir-2.4.0.tgz", + "integrity": "sha512-9EtFOZR8g22CL7BWjJ9BUx1+A/djkofnyW3aOXZORNW2kxoUpx2h+uN2cOqwPmFhnpVmxg+KW2OjOSgChTEvsQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/camelcase": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", @@ -598,6 +1005,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -628,6 +1042,16 @@ "node": ">=8" } }, + "node_modules/check-more-types": { + "version": "2.24.0", + "resolved": "https://registry.npmjs.org/check-more-types/-/check-more-types-2.24.0.tgz", + "integrity": "sha512-Pj779qHxV2tuapviy1bSZNEL1maXr13bPYpsvSDB68HlYcYuhlDrmGd63i0JHMCLKzc7rUSNIrpdJlhVlNwrxA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/chokidar": { "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", @@ -666,19 +1090,91 @@ "node": ">=10" } }, - "node_modules/cliui": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", - "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^7.0.0" + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" } }, - "node_modules/color-convert": { + "node_modules/clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-table3": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.1.tgz", + "integrity": "sha512-w0q/enDHhPLq44ovMGdQeeDLvwxwavsJX7oQGYt/LrBlYsyaxyDnp6z3QzFut/6kLLKnlcUVJLrpB7KBfgG/RA==", + "dev": true, + "license": "MIT", + "dependencies": { + "string-width": "^4.2.0" + }, + "engines": { + "node": "10.* || >= 12.*" + }, + "optionalDependencies": { + "colors": "1.4.0" + } + }, + "node_modules/cli-truncate": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz", + "integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "slice-ansi": "^3.0.0", + "string-width": "^4.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", @@ -698,6 +1194,57 @@ "dev": true, "license": "MIT" }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/colors": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", + "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", + "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/common-tags": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz", + "integrity": "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -705,6 +1252,13 @@ "dev": true, "license": "MIT" }, + "node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "dev": true, + "license": "MIT" + }, "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", @@ -712,6 +1266,119 @@ "dev": true, "license": "MIT" }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cypress": { + "version": "14.5.4", + "resolved": "https://registry.npmjs.org/cypress/-/cypress-14.5.4.tgz", + "integrity": "sha512-0Dhm4qc9VatOcI1GiFGVt8osgpPdqJLHzRwcAB5MSD/CAAts3oybvPUPawHyvJZUd8osADqZe/xzMsZ8sDTjXw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@cypress/request": "^3.0.9", + "@cypress/xvfb": "^1.2.4", + "@types/sinonjs__fake-timers": "8.1.1", + "@types/sizzle": "^2.3.2", + "arch": "^2.2.0", + "blob-util": "^2.0.2", + "bluebird": "^3.7.2", + "buffer": "^5.7.1", + "cachedir": "^2.3.0", + "chalk": "^4.1.0", + "check-more-types": "^2.24.0", + "ci-info": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-table3": "0.6.1", + "commander": "^6.2.1", + "common-tags": "^1.8.0", + "dayjs": "^1.10.4", + "debug": "^4.3.4", + "enquirer": "^2.3.6", + "eventemitter2": "6.4.7", + "execa": "4.1.0", + "executable": "^4.1.1", + "extract-zip": "2.0.1", + "figures": "^3.2.0", + "fs-extra": "^9.1.0", + "getos": "^3.2.1", + "hasha": "5.2.2", + "is-installed-globally": "~0.4.0", + "lazy-ass": "^1.6.0", + "listr2": "^3.8.3", + "lodash": "^4.17.21", + "log-symbols": "^4.0.0", + "minimist": "^1.2.8", + "ospath": "^1.2.2", + "pretty-bytes": "^5.6.0", + "process": "^0.11.10", + "proxy-from-env": "1.0.0", + "request-progress": "^3.0.0", + "semver": "^7.7.1", + "supports-color": "^8.1.1", + "tmp": "~0.2.3", + "tree-kill": "1.2.2", + "untildify": "^4.0.0", + "yauzl": "^2.10.0" + }, + "bin": { + "cypress": "bin/cypress" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + } + }, + "node_modules/cypress/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dashdash": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", + "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==", + "dev": true, + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/dayjs": { + "version": "1.11.20", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "dev": true, + "license": "MIT" + }, "node_modules/debug": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.3.tgz", @@ -750,6 +1417,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/diff": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/diff/-/diff-5.0.0.tgz", @@ -774,6 +1451,39 @@ "dprint": "bin.js" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/duplexer": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", + "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ecc-jsbn": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", + "integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "jsbn": "~0.1.0", + "safer-buffer": "^2.1.0" + } + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -781,6 +1491,30 @@ "dev": true, "license": "MIT" }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/enquirer": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz", + "integrity": "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-colors": "^4.1.1", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8.6" + } + }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", @@ -794,6 +1528,55 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -817,6 +1600,104 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/event-stream": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/event-stream/-/event-stream-3.3.4.tgz", + "integrity": "sha512-QHpkERcGsR0T7Qm3HNJSyXKEEj8AHNxkY3PK8TS2KJvQ7NiSHe3DDpwVKKtoYprL/AreyzFBeIkBIWChAqn60g==", + "dev": true, + "license": "MIT", + "dependencies": { + "duplexer": "~0.1.1", + "from": "~0", + "map-stream": "~0.1.0", + "pause-stream": "0.0.11", + "split": "0.3", + "stream-combiner": "~0.0.4", + "through": "~2.3.1" + } + }, + "node_modules/eventemitter2": { + "version": "6.4.7", + "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.7.tgz", + "integrity": "sha512-tYUSVOGeQPKt/eC1ABfhHy5Xd96N3oIijJvN3O9+TsC28T5V9yX9oEfEK5faP0EFSNVOG97qtAS68GBrQB2hDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/execa": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-4.1.0.tgz", + "integrity": "sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.0", + "get-stream": "^5.0.0", + "human-signals": "^1.1.1", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.0", + "onetime": "^5.1.0", + "signal-exit": "^3.0.2", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/executable": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/executable/-/executable-4.1.1.tgz", + "integrity": "sha512-8iA79xD3uAch729dUG8xaaBBFGaEa0wdD2VkYLFHwlqosEj/jT66AzcreRDSgV7ehnNLBW2WR5jIXwGKjVdTLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.2.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/extsprintf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", + "integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==", + "dev": true, + "engines": [ + "node >=0.6.0" + ], + "license": "MIT" + }, "node_modules/fd-slicer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", @@ -827,6 +1708,32 @@ "pend": "~1.2.0" } }, + "node_modules/figures": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", + "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^1.0.5" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/figures/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -867,8 +1774,79 @@ "flat": "cli.js" } }, - "node_modules/fs-minipass": { - "version": "2.1.0", + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/forever-agent": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", + "integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "*" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/from": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/from/-/from-0.1.7.tgz", + "integrity": "sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/fs-minipass": { + "version": "2.1.0", "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", "dev": true, @@ -915,6 +1893,16 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -925,6 +1913,81 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/getos": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/getos/-/getos-3.2.1.tgz", + "integrity": "sha512-U56CfOK17OKgTVqozZjUKNdkfEv6jk5WISBJ8SHoagjE6L69zOwl3Z+O8myjY9MEW3i2HPWQBt/LTbCgcC973Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "async": "^3.2.0" + } + }, + "node_modules/getpass": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", + "integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0" + } + }, "node_modules/glob": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", @@ -973,6 +2036,35 @@ "node": "*" } }, + "node_modules/global-dirs": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-3.0.1.tgz", + "integrity": "sha512-NBcGGFbBA9s1VzD41QXDG+3++t9Mn5t1FpLdhESY6oKY4gYTFpX4wO3sqGUa0Srjtbfj3szX0RnemmrVRUdULA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/gql.tada": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/gql.tada/-/gql.tada-1.9.0.tgz", @@ -993,6 +2085,13 @@ "typescript": "^5.0.0" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, "node_modules/graphql": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.1.tgz", @@ -1023,6 +2122,65 @@ "node": ">=8" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasha": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/hasha/-/hasha-5.2.2.tgz", + "integrity": "sha512-Hrp5vIK/xr5SkeN2onO32H0MgNZ0f17HRNH39WfL0SYUNOTZ5Lz1TJ8Pajo/87dYGEFlLMm7mIc/k/s6Bvz9HQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-stream": "^2.0.0", + "type-fest": "^0.8.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/he": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", @@ -1033,6 +2191,62 @@ "he": "bin/he" } }, + "node_modules/http-signature": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.4.0.tgz", + "integrity": "sha512-G5akfn7eKbpDN+8nPS/cb57YeA1jLTVxjpCj7tmm3QKPdyDy7T+qSC40e9ptydSWvkwjSXw1VbkpyEm39ukeAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0", + "jsprim": "^2.0.2", + "sshpk": "^1.18.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/human-signals": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz", + "integrity": "sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8.12.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -1052,6 +2266,16 @@ "dev": true, "license": "ISC" }, + "node_modules/ini": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-2.0.0.tgz", + "integrity": "sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -1098,6 +2322,23 @@ "node": ">=0.10.0" } }, + "node_modules/is-installed-globally": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.4.0.tgz", + "integrity": "sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "global-dirs": "^3.0.0", + "is-path-inside": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -1108,6 +2349,16 @@ "node": ">=0.12.0" } }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-plain-obj": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", @@ -1118,6 +2369,26 @@ "node": ">=8" } }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", + "dev": true, + "license": "MIT" + }, "node_modules/is-unicode-supported": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", @@ -1138,6 +2409,32 @@ "dev": true, "license": "ISC" }, + "node_modules/isstream": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", + "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/joi": { + "version": "18.1.2", + "resolved": "https://registry.npmjs.org/joi/-/joi-18.1.2.tgz", + "integrity": "sha512-rF5MAmps5esSlhCA+N1b6IYHDw9j/btzGaqfgie522jS02Ju/HXBxamlXVlKEHAxoMKQL77HWI8jlqWsFuekZA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/address": "^5.1.1", + "@hapi/formula": "^3.0.2", + "@hapi/hoek": "^11.0.7", + "@hapi/pinpoint": "^2.0.1", + "@hapi/tlds": "^1.1.1", + "@hapi/topo": "^6.0.2", + "@standard-schema/spec": "^1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/js-yaml": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", @@ -1151,6 +2448,27 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "dev": true, + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "license": "ISC" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -1164,18 +2482,85 @@ "node": ">=6" } }, - "node_modules/linkify-it": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", - "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", "dev": true, "license": "MIT", "dependencies": { - "uc.micro": "^2.0.0" + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" } }, - "node_modules/locate-path": { - "version": "6.0.0", + "node_modules/jsprim": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-2.0.2.tgz", + "integrity": "sha512-gqXddjPqQ6G40VdnI6T6yObEC+pDNvyP95wdQhkWkg7crHH3km5qP1FsOXEkzEQwnz6gz5qGTn1c2Y52wP3OyQ==", + "dev": true, + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "dependencies": { + "assert-plus": "1.0.0", + "extsprintf": "1.3.0", + "json-schema": "0.4.0", + "verror": "1.10.0" + } + }, + "node_modules/lazy-ass": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/lazy-ass/-/lazy-ass-1.6.0.tgz", + "integrity": "sha512-cc8oEVoctTvsFZ/Oje/kGnHbpWHYBe8IAJe4C0QNc3t8uM/0Y8+erSz/7Y1ALuXTEZTMvxXwO6YbX1ey3ujiZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "> 0.8" + } + }, + "node_modules/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/listr2": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-3.14.0.tgz", + "integrity": "sha512-TyWI8G99GX9GjE54cJ+RrNMcIFBfwMPxc3XTFiAYGN4s10hWROGtOg7+O6u6LE3mNkyld7RSLE6nrKBvTfcs3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cli-truncate": "^2.1.0", + "colorette": "^2.0.16", + "log-update": "^4.0.0", + "p-map": "^4.0.0", + "rfdc": "^1.3.0", + "rxjs": "^7.5.1", + "through": "^2.3.8", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "enquirer": ">= 2.3.0 < 3" + }, + "peerDependenciesMeta": { + "enquirer": { + "optional": true + } + } + }, + "node_modules/locate-path": { + "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, @@ -1190,6 +2575,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "dev": true, + "license": "MIT" + }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", @@ -1207,6 +2606,58 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/log-update": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-4.0.0.tgz", + "integrity": "sha512-9fkkDevMefjg0mmzWFBW8YkFP91OrizzkW3diF7CpG+S2EYdy4+TVfGwz1zeF8x7hCx1ovSPTOE9Ngib74qqUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^4.3.0", + "cli-cursor": "^3.1.0", + "slice-ansi": "^4.0.0", + "wrap-ansi": "^6.2.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/log-update/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/lru-cache": { "version": "11.2.6", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", @@ -1231,6 +2682,12 @@ "dev": true, "license": "ISC" }, + "node_modules/map-stream": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.1.0.tgz", + "integrity": "sha512-CkYQrPYZfWnu/DAmVCpTSX/xHpKZ80eKh2lAkyA6AJTef6bW+6JpbQZN5rofum7da+SyN1bi5ctTm+lTfcCW3g==", + "dev": true + }, "node_modules/markdown-it": { "version": "14.1.1", "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", @@ -1249,6 +2706,16 @@ "markdown-it": "bin/markdown-it.mjs" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/mdurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", @@ -1256,6 +2723,46 @@ "dev": true, "license": "MIT" }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/minimatch": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-4.2.1.tgz", @@ -1424,6 +2931,32 @@ "node": ">=0.10.0" } }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -1434,6 +2967,29 @@ "wrappy": "1" } }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ospath": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/ospath/-/ospath-1.2.2.tgz", + "integrity": "sha512-o6E5qJV5zkAbIDNhGSIlyOhScKXgQrSRMilfph0clDfM0nEnBOlKlH4sWDmG95BW/CvwNz0vmm7dJVtU2KlMiA==", + "dev": true, + "license": "MIT" + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -1466,6 +3022,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-map": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", + "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -1493,6 +3065,16 @@ "node": ">=0.10.0" } }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-scurry": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", @@ -1520,6 +3102,19 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/pause-stream": { + "version": "0.0.11", + "resolved": "https://registry.npmjs.org/pause-stream/-/pause-stream-0.0.11.tgz", + "integrity": "sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==", + "dev": true, + "license": [ + "MIT", + "Apache2" + ], + "dependencies": { + "through": "~2.3" + } + }, "node_modules/pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", @@ -1527,6 +3122,13 @@ "dev": true, "license": "MIT" }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "dev": true, + "license": "MIT" + }, "node_modules/picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", @@ -1536,190 +3138,601 @@ "engines": { "node": ">=8.6" }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pretty-bytes": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", + "integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/proxy-from-env": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.0.0.tgz", + "integrity": "sha512-F2JHgJQ1iqwnHDcQjVBsq3n/uoaFL+iPW/eAeL7kVxy/2RrWaN4WroKjjvbsoRtv0ftelNyC01bjRhn/bhcf4A==", + "dev": true, + "license": "MIT" + }, + "node_modules/ps-tree": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/ps-tree/-/ps-tree-1.2.0.tgz", + "integrity": "sha512-0VnamPPYHl4uaU/nSFeZZpR21QAWRz+sRv4iW9+v/GS/J5U5iZB5BNN6J0RMoOvdx2gWM2+ZFMIm58q24e4UYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "event-stream": "=3.3.4" + }, + "bin": { + "ps-tree": "bin/ps-tree.js" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/request-progress": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/request-progress/-/request-progress-3.0.0.tgz", + "integrity": "sha512-MnWzEHHaxHO2iWiQuHrUPBi/1WeBf5PkxQqNyNvLl9VAYSdXkP8tQ3pBSeCPD+yw0v0Aq1zosWLz0BdeXpWwZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "throttleit": "^1.0.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/rimraf": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", + "integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "glob": "^13.0.3", + "package-json-from-dist": "^1.0.1" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/rimraf/node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/serialize-javascript": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.0.tgz", + "integrity": "sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/slice-ansi": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz", + "integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=8" } }, - "node_modules/punycode.js": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", - "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "dev": true, - "license": "MIT", + "license": "BSD-3-Clause", "engines": { - "node": ">=6" + "node": ">=0.10.0" } }, - "node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", "dev": true, "license": "MIT", "dependencies": { - "safe-buffer": "^5.1.0" + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" } }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "node_modules/split": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/split/-/split-0.3.3.tgz", + "integrity": "sha512-wD2AeVmxXRBoX44wAycgjVpMhvbwdI2aZjCkvfNcH1YqHQvJVa1duWc73OyVGJUc05fhFaTZeQ/PYsrmyH0JVA==", "dev": true, "license": "MIT", "dependencies": { - "picomatch": "^2.2.1" + "through": "2" }, "engines": { - "node": ">=8.10.0" + "node": "*" } }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "node_modules/sshpk": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", + "integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==", "dev": true, "license": "MIT", + "dependencies": { + "asn1": "~0.2.3", + "assert-plus": "^1.0.0", + "bcrypt-pbkdf": "^1.0.0", + "dashdash": "^1.12.0", + "ecc-jsbn": "~0.1.1", + "getpass": "^0.1.1", + "jsbn": "~0.1.0", + "safer-buffer": "^2.0.2", + "tweetnacl": "~0.14.0" + }, + "bin": { + "sshpk-conv": "bin/sshpk-conv", + "sshpk-sign": "bin/sshpk-sign", + "sshpk-verify": "bin/sshpk-verify" + }, "engines": { "node": ">=0.10.0" } }, - "node_modules/rimraf": { - "version": "6.1.3", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", - "integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==", + "node_modules/start-server-and-test": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/start-server-and-test/-/start-server-and-test-2.1.5.tgz", + "integrity": "sha512-A/SbXpgXE25ScSkpLLqvGvVZT0ykN6+AzS8tVqMBCTxbJy2Nwuen59opT+afalK5aS+AuQmZs0EsLwjnuDN+/g==", "dev": true, - "license": "BlueOak-1.0.0", + "license": "MIT", "dependencies": { - "glob": "^13.0.3", - "package-json-from-dist": "^1.0.1" + "arg": "^5.0.2", + "bluebird": "3.7.2", + "check-more-types": "2.24.0", + "debug": "4.4.3", + "execa": "5.1.1", + "lazy-ass": "1.6.0", + "ps-tree": "1.2.0", + "wait-on": "9.0.4" }, "bin": { - "rimraf": "dist/esm/bin.mjs" + "server-test": "src/bin/start.js", + "start-server-and-test": "src/bin/start.js", + "start-test": "src/bin/start.js" }, "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">=16" } }, - "node_modules/rimraf/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "node_modules/start-server-and-test/node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } + "license": "MIT" }, - "node_modules/rimraf/node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "node_modules/start-server-and-test/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^4.0.2" + "ms": "^2.1.3" }, "engines": { - "node": "18 || 20 || >=22" + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, - "node_modules/rimraf/node_modules/glob": { - "version": "13.0.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", - "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "node_modules/start-server-and-test/node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", "dev": true, - "license": "BlueOak-1.0.0", + "license": "MIT", "dependencies": { - "minimatch": "^10.2.2", - "minipass": "^7.1.3", - "path-scurry": "^2.0.2" + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" }, "engines": { - "node": "18 || 20 || >=22" + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, - "node_modules/rimraf/node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "node_modules/start-server-and-test/node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, + "license": "MIT", "engines": { - "node": "18 || 20 || >=22" + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rimraf/node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/serialize-javascript": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.0.tgz", - "integrity": "sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "randombytes": "^2.1.0" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "node_modules/start-server-and-test/node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", "dev": true, - "license": "BSD-3-Clause", + "license": "Apache-2.0", "engines": { - "node": ">=0.10.0" + "node": ">=10.17.0" } }, - "node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "node_modules/stream-combiner": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/stream-combiner/-/stream-combiner-0.0.4.tgz", + "integrity": "sha512-rT00SPnTVyRsaSz5zgSPma/aHSOic5U1prhYdRy5HS2kTZviFpmDgzilbtsJsxiroqACmayynDN/9VzIbX5DOw==", "dev": true, "license": "MIT", "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" + "duplexer": "~0.1.1" } }, "node_modules/string-width": { @@ -1760,6 +3773,16 @@ "node": ">=4" } }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -1808,6 +3831,53 @@ "node": ">=10" } }, + "node_modules/throttleit": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-1.0.1.tgz", + "integrity": "sha512-vDZpf9Chs9mAdfY046mcPt8fg5QSZr37hEH4TXYBnDF+izxgrbRGUAAaBvIk/fJm9aOFCGFd1EsNg5AZCbnQCQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tmp": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -1821,6 +3891,19 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -1828,6 +3911,16 @@ "dev": true, "license": "MIT" }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, "node_modules/ts-mocha": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/ts-mocha/-/ts-mocha-9.0.2.tgz", @@ -2003,6 +4096,43 @@ "node": ">=6" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "dev": true, + "license": "Unlicense" + }, + "node_modules/type-fest": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", + "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=8" + } + }, "node_modules/typedoc": { "version": "0.28.17", "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.28.17.tgz", @@ -2093,6 +4223,36 @@ "dev": true, "license": "MIT" }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/untildify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", + "integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true, + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", @@ -2115,6 +4275,41 @@ } } }, + "node_modules/verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", + "dev": true, + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + }, + "node_modules/wait-on": { + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-9.0.4.tgz", + "integrity": "sha512-k8qrgfwrPVJXTeFY8tl6BxVHiclK11u72DVKhpybHfUL/K6KM4bdyK9EhIVYGytB5MJe/3lq4Tf0hrjM+pvJZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "axios": "^1.13.5", + "joi": "^18.0.2", + "lodash": "^4.17.23", + "minimist": "^1.2.8", + "rxjs": "^7.8.2" + }, + "bin": { + "wait-on": "bin/wait-on" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/wasm-opt": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/wasm-opt/-/wasm-opt-1.4.0.tgz", From 090d3134b04cabe7d7cef4c12cc4daa2830bef4e Mon Sep 17 00:00:00 2001 From: Yasir Date: Thu, 9 Apr 2026 12:30:40 +0300 Subject: [PATCH 150/189] fix: polish audit trail browser examples --- bindings/wasm/audit_trail_wasm/cypress/e2e/tests.cy.js | 4 ++-- bindings/wasm/audit_trail_wasm/src/trail_handle/tags.rs | 3 +-- examples/Cargo.toml | 1 - examples/audit-trail/real-world/01_customs_clearance.rs | 9 ++++++--- examples/audit-trail/real-world/02_clinical_trial.rs | 9 ++++++--- examples/utils/utils.rs | 2 +- 6 files changed, 16 insertions(+), 12 deletions(-) diff --git a/bindings/wasm/audit_trail_wasm/cypress/e2e/tests.cy.js b/bindings/wasm/audit_trail_wasm/cypress/e2e/tests.cy.js index 883e2896..960ab311 100644 --- a/bindings/wasm/audit_trail_wasm/cypress/e2e/tests.cy.js +++ b/bindings/wasm/audit_trail_wasm/cypress/e2e/tests.cy.js @@ -27,8 +27,8 @@ describe( }, }); cy.get("@consoleLog").should("be.calledWith", "init"); - cy.window().then(win => win.runTest(example)); - cy.get("@consoleLog").should("be.calledWith", "success"); + cy.window({ timeout: 180000 }).then({ timeout: 180000 }, (win) => win.runTest(example)); + cy.get("@consoleLog", { timeout: 180000 }).should("be.calledWith", "success"); }); }); }, diff --git a/bindings/wasm/audit_trail_wasm/src/trail_handle/tags.rs b/bindings/wasm/audit_trail_wasm/src/trail_handle/tags.rs index 65da9ce0..4bfa4552 100644 --- a/bindings/wasm/audit_trail_wasm/src/trail_handle/tags.rs +++ b/bindings/wasm/audit_trail_wasm/src/trail_handle/tags.rs @@ -2,8 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 use anyhow::anyhow; -use audit_trail::AuditTrailClient; -use audit_trail::AuditTrailClientReadOnly; +use audit_trail::{AuditTrailClient, AuditTrailClientReadOnly}; use iota_interaction::types::base_types::ObjectID; use iota_interaction_ts::bindings::WasmTransactionSigner; use iota_interaction_ts::wasm_error::{wasm_error, Result, WasmResult}; diff --git a/examples/Cargo.toml b/examples/Cargo.toml index 9b68a7c1..65a68255 100644 --- a/examples/Cargo.toml +++ b/examples/Cargo.toml @@ -105,7 +105,6 @@ anyhow.workspace = true audit_trail = { path = "../audit-trail-rs" } chrono = { workspace = true } iota-sdk = { workspace = true } -iota_interaction = { workspace = true } notarization = { path = "../notarization-rs" } product_common = { workspace = true, features = ["core-client", "test-utils", "transaction"] } serde_json = { workspace = true } diff --git a/examples/audit-trail/real-world/01_customs_clearance.rs b/examples/audit-trail/real-world/01_customs_clearance.rs index 68837692..5a4951e6 100644 --- a/examples/audit-trail/real-world/01_customs_clearance.rs +++ b/examples/audit-trail/real-world/01_customs_clearance.rs @@ -14,12 +14,15 @@ //! - 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, }; use examples::get_funded_audit_trail_client; +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<()> { @@ -274,11 +277,11 @@ async fn main() -> Result<()> { } async fn issue_tagged_record_role( - client: &audit_trail::AuditTrailClient, - trail_id: iota_interaction::types::base_types::ObjectID, + client: &AuditTrailClient, + trail_id: ObjectID, role_name: &str, tag: &str, - issued_to: iota_interaction::types::base_types::IotaAddress, + issued_to: IotaAddress, ) -> Result<()> { client .trail(trail_id) diff --git a/examples/audit-trail/real-world/02_clinical_trial.rs b/examples/audit-trail/real-world/02_clinical_trial.rs index b0c05cff..8966f36b 100644 --- a/examples/audit-trail/real-world/02_clinical_trial.rs +++ b/examples/audit-trail/real-world/02_clinical_trial.rs @@ -18,12 +18,15 @@ //! - 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, }; use examples::get_funded_audit_trail_client; +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<()> { @@ -335,11 +338,11 @@ async fn main() -> Result<()> { } async fn issue_tagged_record_role( - client: &audit_trail::AuditTrailClient, - trail_id: iota_interaction::types::base_types::ObjectID, + client: &AuditTrailClient, + trail_id: ObjectID, role_name: &str, tag: &str, - issued_to: iota_interaction::types::base_types::IotaAddress, + issued_to: IotaAddress, ) -> Result<()> { client .trail(trail_id) diff --git a/examples/utils/utils.rs b/examples/utils/utils.rs index 1c330329..28ea0d9b 100644 --- a/examples/utils/utils.rs +++ b/examples/utils/utils.rs @@ -3,7 +3,7 @@ use anyhow::Context; use audit_trail::{AuditTrailClient, PackageOverrides}; -use iota_interaction::types::base_types::ObjectID; +use iota_sdk::types::base_types::ObjectID; use iota_sdk::{IOTA_LOCAL_NETWORK_URL, IotaClientBuilder}; use notarization::client::{NotarizationClient, NotarizationClientReadOnly}; use product_common::test_utils::{InMemSigner, request_funds}; From 7250ca7caf117b1a35350551f41ce620eee59549 Mon Sep 17 00:00:00 2001 From: Yasir Date: Thu, 9 Apr 2026 17:04:37 +0300 Subject: [PATCH 151/189] feat: add explicit audit trail capability selection --- audit-trail-rs/src/core/access/mod.rs | 70 ++++- audit-trail-rs/src/core/access/operations.rs | 16 + .../src/core/access/transactions.rs | 64 +++- .../src/core/internal/capability.rs | 118 ++++++- audit-trail-rs/src/core/internal/tx.rs | 9 +- audit-trail-rs/src/core/locking/mod.rs | 43 ++- audit-trail-rs/src/core/locking/operations.rs | 8 + .../src/core/locking/transactions.rs | 72 ++++- audit-trail-rs/src/core/records/mod.rs | 33 +- audit-trail-rs/src/core/records/operations.rs | 53 +--- .../src/core/records/transactions.rs | 35 ++- audit-trail-rs/src/core/tags/mod.rs | 29 +- audit-trail-rs/src/core/tags/operations.rs | 4 + audit-trail-rs/src/core/tags/transactions.rs | 26 +- audit-trail-rs/src/core/trail.rs | 32 +- audit-trail-rs/src/core/trail/operations.rs | 21 +- audit-trail-rs/src/core/trail/transactions.rs | 30 +- audit-trail-rs/tests/e2e/records.rs | 288 +++++++++++++++--- 18 files changed, 793 insertions(+), 158 deletions(-) diff --git a/audit-trail-rs/src/core/access/mod.rs b/audit-trail-rs/src/core/access/mod.rs index 25a155b3..b2ef3b81 100644 --- a/audit-trail-rs/src/core/access/mod.rs +++ b/audit-trail-rs/src/core/access/mod.rs @@ -22,16 +22,27 @@ pub use transactions::{ pub struct TrailAccess<'a, C> { pub(crate) client: &'a C, pub(crate) trail_id: ObjectID, + pub(crate) selected_capability_id: Option, } impl<'a, C> TrailAccess<'a, C> { - pub(crate) fn new(client: &'a C, trail_id: ObjectID) -> Self { - Self { client, trail_id } + pub(crate) fn new(client: &'a C, trail_id: ObjectID, selected_capability_id: Option) -> Self { + Self { + client, + trail_id, + selected_capability_id, + } + } + + /// Uses the provided capability as the auth capability for subsequent write operations. + pub fn using_capability(mut self, capability_id: ObjectID) -> Self { + self.selected_capability_id = Some(capability_id); + self } /// Returns a handle bound to a specific role name. pub fn for_role(&self, name: impl Into) -> RoleHandle<'a, C> { - RoleHandle::new(self.client, self.trail_id, name.into()) + RoleHandle::new(self.client, self.trail_id, name.into(), self.selected_capability_id) } /// Revokes an issued capability. @@ -53,6 +64,7 @@ impl<'a, C> TrailAccess<'a, C> { owner, capability_id, capability_valid_until, + self.selected_capability_id, )) } @@ -63,7 +75,12 @@ impl<'a, C> TrailAccess<'a, C> { S: Signer + OptionalSync, { let owner = self.client.sender_address(); - TransactionBuilder::new(DestroyCapability::new(self.trail_id, owner, capability_id)) + TransactionBuilder::new(DestroyCapability::new( + self.trail_id, + owner, + capability_id, + self.selected_capability_id, + )) } /// Destroys an initial admin capability (self-service, no auth cap required). @@ -97,6 +114,7 @@ impl<'a, C> TrailAccess<'a, C> { owner, capability_id, capability_valid_until, + self.selected_capability_id, )) } @@ -107,7 +125,11 @@ impl<'a, C> TrailAccess<'a, C> { S: Signer + OptionalSync, { let owner = self.client.sender_address(); - TransactionBuilder::new(CleanupRevokedCapabilities::new(self.trail_id, owner)) + TransactionBuilder::new(CleanupRevokedCapabilities::new( + self.trail_id, + owner, + self.selected_capability_id, + )) } } @@ -116,11 +138,28 @@ pub struct RoleHandle<'a, C> { pub(crate) client: &'a C, pub(crate) trail_id: ObjectID, pub(crate) name: String, + pub(crate) selected_capability_id: Option, } impl<'a, C> RoleHandle<'a, C> { - pub(crate) fn new(client: &'a C, trail_id: ObjectID, name: String) -> Self { - Self { client, trail_id, name } + pub(crate) fn new( + client: &'a C, + trail_id: ObjectID, + name: String, + selected_capability_id: Option, + ) -> Self { + Self { + client, + trail_id, + name, + selected_capability_id, + } + } + + /// Uses the provided capability as the auth capability for subsequent write operations. + pub fn using_capability(mut self, capability_id: ObjectID) -> Self { + self.selected_capability_id = Some(capability_id); + self } pub fn name(&self) -> &str { @@ -140,6 +179,7 @@ impl<'a, C> RoleHandle<'a, C> { self.name.clone(), permissions, role_tags, + self.selected_capability_id, )) } @@ -150,7 +190,13 @@ impl<'a, C> RoleHandle<'a, C> { S: Signer + OptionalSync, { let owner = self.client.sender_address(); - TransactionBuilder::new(IssueCapability::new(self.trail_id, owner, self.name.clone(), options)) + TransactionBuilder::new(IssueCapability::new( + self.trail_id, + owner, + self.name.clone(), + options, + self.selected_capability_id, + )) } /// Updates permissions and role-tag access rules for this role. @@ -170,6 +216,7 @@ impl<'a, C> RoleHandle<'a, C> { self.name.clone(), permissions, role_tags, + self.selected_capability_id, )) } @@ -180,6 +227,11 @@ impl<'a, C> RoleHandle<'a, C> { S: Signer + OptionalSync, { let owner = self.client.sender_address(); - TransactionBuilder::new(DeleteRole::new(self.trail_id, owner, self.name.clone())) + TransactionBuilder::new(DeleteRole::new( + self.trail_id, + owner, + self.name.clone(), + self.selected_capability_id, + )) } } diff --git a/audit-trail-rs/src/core/access/operations.rs b/audit-trail-rs/src/core/access/operations.rs index 0e57ea41..574cc6b3 100644 --- a/audit-trail-rs/src/core/access/operations.rs +++ b/audit-trail-rs/src/core/access/operations.rs @@ -20,6 +20,7 @@ impl AccessOps { name: String, permissions: PermissionSet, role_tags: Option, + selected_capability_id: Option, ) -> Result where C: CoreClientReadOnly + OptionalSync, @@ -31,6 +32,7 @@ impl AccessOps { trail_id, owner, Permission::AddRoles, + selected_capability_id, "create_role", |ptb, _| { let role = tx::ptb_pure(ptb, "role", name)?; @@ -67,6 +69,7 @@ impl AccessOps { name: String, permissions: PermissionSet, role_tags: Option, + selected_capability_id: Option, ) -> Result where C: CoreClientReadOnly + OptionalSync, @@ -78,6 +81,7 @@ impl AccessOps { trail_id, owner, Permission::UpdateRoles, + selected_capability_id, "update_role_permissions", |ptb, _| { let role = tx::ptb_pure(ptb, "role", name)?; @@ -113,6 +117,7 @@ impl AccessOps { trail_id: ObjectID, owner: IotaAddress, name: String, + selected_capability_id: Option, ) -> Result where C: CoreClientReadOnly + OptionalSync, @@ -122,6 +127,7 @@ impl AccessOps { trail_id, owner, Permission::DeleteRoles, + selected_capability_id, "delete_role", |ptb, _| { let role = tx::ptb_pure(ptb, "role", name)?; @@ -139,6 +145,7 @@ impl AccessOps { owner: IotaAddress, role_name: String, options: CapabilityIssueOptions, + selected_capability_id: Option, ) -> Result where C: CoreClientReadOnly + OptionalSync, @@ -148,6 +155,7 @@ impl AccessOps { trail_id, owner, Permission::AddCapabilities, + selected_capability_id, "new_capability", |ptb, _| { let role = tx::ptb_pure(ptb, "role", role_name)?; @@ -168,6 +176,7 @@ impl AccessOps { owner: IotaAddress, capability_id: ObjectID, capability_valid_until: Option, + selected_capability_id: Option, ) -> Result where C: CoreClientReadOnly + OptionalSync, @@ -177,6 +186,7 @@ impl AccessOps { trail_id, owner, Permission::RevokeCapabilities, + selected_capability_id, "revoke_capability", |ptb, _| { let cap = tx::ptb_pure(ptb, "capability_id", capability_id)?; @@ -194,6 +204,7 @@ impl AccessOps { trail_id: ObjectID, owner: IotaAddress, capability_id: ObjectID, + selected_capability_id: Option, ) -> Result where C: CoreClientReadOnly + OptionalSync, @@ -205,6 +216,7 @@ impl AccessOps { trail_id, owner, Permission::RevokeCapabilities, + selected_capability_id, "destroy_capability", |ptb, _| { let cap_to_destroy = ptb @@ -243,6 +255,7 @@ impl AccessOps { owner: IotaAddress, capability_id: ObjectID, capability_valid_until: Option, + selected_capability_id: Option, ) -> Result where C: CoreClientReadOnly + OptionalSync, @@ -252,6 +265,7 @@ impl AccessOps { trail_id, owner, Permission::RevokeCapabilities, + selected_capability_id, "revoke_initial_admin_capability", |ptb, _| { let cap = tx::ptb_pure(ptb, "capability_id", capability_id)?; @@ -268,6 +282,7 @@ impl AccessOps { client: &C, trail_id: ObjectID, owner: IotaAddress, + selected_capability_id: Option, ) -> Result where C: CoreClientReadOnly + OptionalSync, @@ -277,6 +292,7 @@ impl AccessOps { trail_id, owner, Permission::RevokeCapabilities, + selected_capability_id, "cleanup_revoked_capabilities", |ptb, _| { let clock = tx::get_clock_ref(ptb); diff --git a/audit-trail-rs/src/core/access/transactions.rs b/audit-trail-rs/src/core/access/transactions.rs index b26d605d..1d7dee43 100644 --- a/audit-trail-rs/src/core/access/transactions.rs +++ b/audit-trail-rs/src/core/access/transactions.rs @@ -26,6 +26,7 @@ pub struct CreateRole { name: String, permissions: PermissionSet, role_tags: Option, + selected_capability_id: Option, cached_ptb: OnceCell, } @@ -36,6 +37,7 @@ impl CreateRole { name: String, permissions: PermissionSet, role_tags: Option, + selected_capability_id: Option, ) -> Self { Self { trail_id, @@ -43,6 +45,7 @@ impl CreateRole { name, permissions, role_tags, + selected_capability_id, cached_ptb: OnceCell::new(), } } @@ -58,6 +61,7 @@ impl CreateRole { self.name.clone(), self.permissions.clone(), self.role_tags.clone(), + self.selected_capability_id, ) .await } @@ -109,6 +113,7 @@ pub struct UpdateRole { name: String, permissions: PermissionSet, role_tags: Option, + selected_capability_id: Option, cached_ptb: OnceCell, } @@ -119,6 +124,7 @@ impl UpdateRole { name: String, permissions: PermissionSet, role_tags: Option, + selected_capability_id: Option, ) -> Self { Self { trail_id, @@ -126,6 +132,7 @@ impl UpdateRole { name, permissions, role_tags, + selected_capability_id, cached_ptb: OnceCell::new(), } } @@ -141,6 +148,7 @@ impl UpdateRole { self.name.clone(), self.permissions.clone(), self.role_tags.clone(), + self.selected_capability_id, ) .await } @@ -190,15 +198,17 @@ pub struct DeleteRole { trail_id: ObjectID, owner: IotaAddress, name: String, + selected_capability_id: Option, cached_ptb: OnceCell, } impl DeleteRole { - pub fn new(trail_id: ObjectID, owner: IotaAddress, name: String) -> Self { + pub fn new(trail_id: ObjectID, owner: IotaAddress, name: String, selected_capability_id: Option) -> Self { Self { trail_id, owner, name, + selected_capability_id, cached_ptb: OnceCell::new(), } } @@ -207,7 +217,14 @@ impl DeleteRole { where C: CoreClientReadOnly + OptionalSync, { - AccessOps::delete_role(client, self.trail_id, self.owner, self.name.clone()).await + AccessOps::delete_role( + client, + self.trail_id, + self.owner, + self.name.clone(), + self.selected_capability_id, + ) + .await } } @@ -256,16 +273,24 @@ pub struct IssueCapability { owner: IotaAddress, role: String, options: CapabilityIssueOptions, + selected_capability_id: Option, cached_ptb: OnceCell, } impl IssueCapability { - pub fn new(trail_id: ObjectID, owner: IotaAddress, role: String, options: CapabilityIssueOptions) -> Self { + pub fn new( + trail_id: ObjectID, + owner: IotaAddress, + role: String, + options: CapabilityIssueOptions, + selected_capability_id: Option, + ) -> Self { Self { trail_id, owner, role, options, + selected_capability_id, cached_ptb: OnceCell::new(), } } @@ -280,6 +305,7 @@ impl IssueCapability { self.owner, self.role.clone(), self.options.clone(), + self.selected_capability_id, ) .await } @@ -330,6 +356,7 @@ pub struct RevokeCapability { owner: IotaAddress, capability_id: ObjectID, capability_valid_until: Option, + selected_capability_id: Option, cached_ptb: OnceCell, } @@ -339,12 +366,14 @@ impl RevokeCapability { owner: IotaAddress, capability_id: ObjectID, capability_valid_until: Option, + selected_capability_id: Option, ) -> Self { Self { trail_id, owner, capability_id, capability_valid_until, + selected_capability_id, cached_ptb: OnceCell::new(), } } @@ -359,6 +388,7 @@ impl RevokeCapability { self.owner, self.capability_id, self.capability_valid_until, + self.selected_capability_id, ) .await } @@ -408,15 +438,22 @@ pub struct DestroyCapability { trail_id: ObjectID, owner: IotaAddress, capability_id: ObjectID, + selected_capability_id: Option, cached_ptb: OnceCell, } impl DestroyCapability { - pub fn new(trail_id: ObjectID, owner: IotaAddress, capability_id: ObjectID) -> Self { + pub fn new( + trail_id: ObjectID, + owner: IotaAddress, + capability_id: ObjectID, + selected_capability_id: Option, + ) -> Self { Self { trail_id, owner, capability_id, + selected_capability_id, cached_ptb: OnceCell::new(), } } @@ -425,7 +462,14 @@ impl DestroyCapability { where C: CoreClientReadOnly + OptionalSync, { - AccessOps::destroy_capability(client, self.trail_id, self.owner, self.capability_id).await + AccessOps::destroy_capability( + client, + self.trail_id, + self.owner, + self.capability_id, + self.selected_capability_id, + ) + .await } } @@ -541,6 +585,7 @@ pub struct RevokeInitialAdminCapability { owner: IotaAddress, capability_id: ObjectID, capability_valid_until: Option, + selected_capability_id: Option, cached_ptb: OnceCell, } @@ -550,12 +595,14 @@ impl RevokeInitialAdminCapability { owner: IotaAddress, capability_id: ObjectID, capability_valid_until: Option, + selected_capability_id: Option, ) -> Self { Self { trail_id, owner, capability_id, capability_valid_until, + selected_capability_id, cached_ptb: OnceCell::new(), } } @@ -570,6 +617,7 @@ impl RevokeInitialAdminCapability { self.owner, self.capability_id, self.capability_valid_until, + self.selected_capability_id, ) .await } @@ -618,14 +666,16 @@ impl Transaction for RevokeInitialAdminCapability { pub struct CleanupRevokedCapabilities { trail_id: ObjectID, owner: IotaAddress, + selected_capability_id: Option, cached_ptb: OnceCell, } impl CleanupRevokedCapabilities { - pub fn new(trail_id: ObjectID, owner: IotaAddress) -> Self { + pub fn new(trail_id: ObjectID, owner: IotaAddress, selected_capability_id: Option) -> Self { Self { trail_id, owner, + selected_capability_id, cached_ptb: OnceCell::new(), } } @@ -634,7 +684,7 @@ impl CleanupRevokedCapabilities { where C: CoreClientReadOnly + OptionalSync, { - AccessOps::cleanup_revoked_capabilities(client, self.trail_id, self.owner).await + AccessOps::cleanup_revoked_capabilities(client, self.trail_id, self.owner, self.selected_capability_id).await } } diff --git a/audit-trail-rs/src/core/internal/capability.rs b/audit-trail-rs/src/core/internal/capability.rs index 0fded2b6..05f5c41d 100644 --- a/audit-trail-rs/src/core/internal/capability.rs +++ b/audit-trail-rs/src/core/internal/capability.rs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 use std::collections::HashSet; +use std::time::{SystemTime, UNIX_EPOCH}; use iota_interaction::move_types::language_storage::StructTag; use iota_interaction::rpc_types::{ @@ -18,6 +19,14 @@ use super::{linked_table, tx}; use crate::core::types::{Capability, OnChainAuditTrail, Permission}; use crate::error::Error; +/// Finds an owned capability that grants `permission` on `trail_id`. +/// +/// This is the standard lookup path used by most trail operations. It derives +/// the set of role names that grant the requested permission from the current +/// on-chain trail state, then delegates the actual owned-object scan to +/// [`find_owned_capability`]. The selected capability is returned as an +/// [`ObjectRef`] because transaction construction needs the live object +/// reference, not just the parsed capability payload. pub(crate) async fn find_capable_cap( client: &C, owner: IotaAddress, @@ -51,6 +60,16 @@ where tx::get_object_ref_by_id(client, &object_id).await } +/// Finds the first owned capability that survives common local filtering. +/// +/// This helper is the generic capability scanner used by the more specific +/// permission-based and tag-aware lookup functions below. It handles: +/// - fetching owned capability objects page by page, +/// - excluding revoked capability IDs recorded on the trail, and +/// - enforcing any `issued_to` address restriction locally. +/// +/// The caller supplies the remaining policy via `predicate`, typically matching +/// the target trail and one or more allowed role names. pub(crate) async fn find_owned_capability( client: &C, owner: IotaAddress, @@ -62,6 +81,10 @@ where P: Fn(&Capability) -> bool + Send, { let revoked_capability_ids = revoked_capability_ids(client, trail).await?; + let now_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; let tf_components_package_id = client .tf_components_package_id() .expect("TfComponents package ID should be present for audit trail clients"); @@ -93,7 +116,7 @@ where }; serde_json::from_value(move_object.fields.to_json_value()).ok() }) - .find(|cap| capability_matches(cap, owner, &revoked_capability_ids, &predicate)); + .find(|cap| capability_matches(cap, owner, now_ms, &revoked_capability_ids, &predicate)); cursor = page.next_cursor; if maybe_cap.is_some() { @@ -107,6 +130,10 @@ where Ok(None) } +/// Loads the current revoked-capability denylist from the trail's linked table. +/// +/// The resulting set is used during local capability selection so revoked +/// capabilities are ignored before transaction construction. async fn revoked_capability_ids(client: &C, trail: &OnChainAuditTrail) -> Result, Error> where C: CoreClientReadOnly + OptionalSync, @@ -146,9 +173,17 @@ where Ok(keys) } +/// Applies the shared local capability filters. +/// +/// A capability is considered usable locally when: +/// - the caller-specific predicate matches, +/// - the capability ID is not present in the trail's revoked-capability set, and +/// - any `issued_to` restriction matches the current owner address, and +/// - the current local time falls within the capability's validity window. fn capability_matches

( cap: &Capability, owner: IotaAddress, + now_ms: u64, revoked_capability_ids: &HashSet, predicate: &P, ) -> bool @@ -158,6 +193,49 @@ where predicate(cap) && !revoked_capability_ids.contains(cap.id.object_id()) && cap.issued_to.map(|issued_to| issued_to == owner).unwrap_or(true) + && cap.valid_from.is_none_or(|valid_from| now_ms >= valid_from) + && cap.valid_until.is_none_or(|valid_until| now_ms <= valid_until) +} + +/// Finds an owned capability for adding a tagged record. +/// +/// Tagged writes have stricter lookup rules than ordinary permission-based +/// operations: the selected role must grant `AddRecord` and its configured +/// `RoleTags` must allow the requested record tag. +pub(crate) async fn find_capable_cap_for_tag( + client: &C, + owner: IotaAddress, + trail_id: ObjectID, + trail: &OnChainAuditTrail, + tag: &str, +) -> Result +where + C: CoreClientReadOnly + OptionalSync, +{ + let valid_roles = trail + .roles + .roles + .iter() + .filter(|(_, role)| { + role.permissions.contains(&Permission::AddRecord) + && role.data.as_ref().is_some_and(|record_tags| record_tags.allows(tag)) + }) + .map(|(name, _)| name.clone()) + .collect::>(); + + let cap = find_owned_capability(client, owner, trail, |cap| { + cap.target_key == trail_id && valid_roles.contains(&cap.role) + }) + .await? + .ok_or_else(|| { + Error::InvalidArgument(format!( + "no capability with {:?} permission and record tag '{tag}' found for owner {owner} and trail {trail_id}", + Permission::AddRecord + )) + })?; + + let object_id = *cap.id.object_id(); + tx::get_object_ref_by_id(client, &object_id).await } #[cfg(test)] @@ -182,9 +260,9 @@ mod tests { let revoked_cap = make_capability(revoked_cap_id, trail_id, "Writer", None); let valid_cap = make_capability(valid_cap_id, trail_id, "Writer", None); - assert!(!capability_matches(&revoked_cap, owner, &revoked_ids, &|cap| cap + assert!(!capability_matches(&revoked_cap, owner, 0, &revoked_ids, &|cap| cap .matches_target_and_role(trail_id, &valid_roles))); - assert!(capability_matches(&valid_cap, owner, &revoked_ids, &|cap| cap + assert!(capability_matches(&valid_cap, owner, 0, &revoked_ids, &|cap| cap .matches_target_and_role(trail_id, &valid_roles))); } @@ -196,7 +274,39 @@ mod tests { let valid_roles = HashSet::from(["Writer".to_string()]); let cap = make_capability(dbg_object_id(5), trail_id, "Writer", Some(other_owner)); - assert!(!capability_matches(&cap, owner, &HashSet::new(), &|candidate| { + assert!(!capability_matches(&cap, owner, 0, &HashSet::new(), &|candidate| { + candidate.matches_target_and_role(trail_id, &valid_roles) + })); + } + + #[test] + fn capability_matches_skips_caps_before_valid_from() { + let owner = IotaAddress::random_for_testing_only(); + let trail_id = dbg_object_id(6); + let valid_roles = HashSet::from(["Writer".to_string()]); + let mut cap = make_capability(dbg_object_id(7), trail_id, "Writer", None); + cap.valid_from = Some(2_000); + + assert!(!capability_matches(&cap, owner, 1_999, &HashSet::new(), &|candidate| { + candidate.matches_target_and_role(trail_id, &valid_roles) + })); + assert!(capability_matches(&cap, owner, 2_000, &HashSet::new(), &|candidate| { + candidate.matches_target_and_role(trail_id, &valid_roles) + })); + } + + #[test] + fn capability_matches_skips_caps_after_valid_until() { + let owner = IotaAddress::random_for_testing_only(); + let trail_id = dbg_object_id(8); + let valid_roles = HashSet::from(["Writer".to_string()]); + let mut cap = make_capability(dbg_object_id(9), trail_id, "Writer", None); + cap.valid_until = Some(2_000); + + assert!(capability_matches(&cap, owner, 2_000, &HashSet::new(), &|candidate| { + candidate.matches_target_and_role(trail_id, &valid_roles) + })); + assert!(!capability_matches(&cap, owner, 2_001, &HashSet::new(), &|candidate| { candidate.matches_target_and_role(trail_id, &valid_roles) })); } diff --git a/audit-trail-rs/src/core/internal/tx.rs b/audit-trail-rs/src/core/internal/tx.rs index d379536d..32fcb1db 100644 --- a/audit-trail-rs/src/core/internal/tx.rs +++ b/audit-trail-rs/src/core/internal/tx.rs @@ -72,6 +72,7 @@ pub(crate) async fn build_trail_transaction( trail_id: ObjectID, owner: IotaAddress, permission: Permission, + selected_capability_id: Option, method: impl AsRef, additional_args: F, ) -> Result @@ -79,8 +80,12 @@ where F: FnOnce(&mut ProgrammableTransactionBuilder, &TypeTag) -> Result, Error>, C: CoreClientReadOnly + OptionalSync, { - let trail = trail_reader::get_audit_trail(trail_id, client).await?; - let cap_ref = capability::find_capable_cap(client, owner, trail_id, &trail, permission).await?; + let cap_ref = if let Some(capability_id) = selected_capability_id { + get_object_ref_by_id(client, &capability_id).await? + } else { + let trail = trail_reader::get_audit_trail(trail_id, client).await?; + capability::find_capable_cap(client, owner, trail_id, &trail, permission).await? + }; build_trail_transaction_with_cap_ref(client, trail_id, cap_ref, method, additional_args).await } diff --git a/audit-trail-rs/src/core/locking/mod.rs b/audit-trail-rs/src/core/locking/mod.rs index 9a26e9c3..e9f91d04 100644 --- a/audit-trail-rs/src/core/locking/mod.rs +++ b/audit-trail-rs/src/core/locking/mod.rs @@ -22,11 +22,22 @@ use self::operations::LockingOps; pub struct TrailLocking<'a, C> { pub(crate) client: &'a C, pub(crate) trail_id: ObjectID, + pub(crate) selected_capability_id: Option, } impl<'a, C> TrailLocking<'a, C> { - pub(crate) fn new(client: &'a C, trail_id: ObjectID) -> Self { - Self { client, trail_id } + pub(crate) fn new(client: &'a C, trail_id: ObjectID, selected_capability_id: Option) -> Self { + Self { + client, + trail_id, + selected_capability_id, + } + } + + /// Uses the provided capability as the auth capability for subsequent write operations. + pub fn using_capability(mut self, capability_id: ObjectID) -> Self { + self.selected_capability_id = Some(capability_id); + self } pub fn update(&self, config: LockingConfig) -> TransactionBuilder @@ -35,7 +46,12 @@ impl<'a, C> TrailLocking<'a, C> { S: Signer + OptionalSync, { let owner = self.client.sender_address(); - TransactionBuilder::new(UpdateLockingConfig::new(self.trail_id, owner, config)) + TransactionBuilder::new(UpdateLockingConfig::new( + self.trail_id, + owner, + config, + self.selected_capability_id, + )) } pub fn update_delete_record_window(&self, window: LockingWindow) -> TransactionBuilder @@ -44,7 +60,12 @@ impl<'a, C> TrailLocking<'a, C> { S: Signer + OptionalSync, { let owner = self.client.sender_address(); - TransactionBuilder::new(UpdateDeleteRecordWindow::new(self.trail_id, owner, window)) + TransactionBuilder::new(UpdateDeleteRecordWindow::new( + self.trail_id, + owner, + window, + self.selected_capability_id, + )) } pub fn update_delete_trail_lock(&self, lock: TimeLock) -> TransactionBuilder @@ -53,7 +74,12 @@ impl<'a, C> TrailLocking<'a, C> { S: Signer + OptionalSync, { let owner = self.client.sender_address(); - TransactionBuilder::new(UpdateDeleteTrailLock::new(self.trail_id, owner, lock)) + TransactionBuilder::new(UpdateDeleteTrailLock::new( + self.trail_id, + owner, + lock, + self.selected_capability_id, + )) } pub fn update_write_lock(&self, lock: TimeLock) -> TransactionBuilder @@ -62,7 +88,12 @@ impl<'a, C> TrailLocking<'a, C> { S: Signer + OptionalSync, { let owner = self.client.sender_address(); - TransactionBuilder::new(UpdateWriteLock::new(self.trail_id, owner, lock)) + TransactionBuilder::new(UpdateWriteLock::new( + self.trail_id, + owner, + lock, + self.selected_capability_id, + )) } pub async fn is_record_locked(&self, sequence_number: u64) -> Result diff --git a/audit-trail-rs/src/core/locking/operations.rs b/audit-trail-rs/src/core/locking/operations.rs index 9d1a9469..ed726be3 100644 --- a/audit-trail-rs/src/core/locking/operations.rs +++ b/audit-trail-rs/src/core/locking/operations.rs @@ -18,6 +18,7 @@ impl LockingOps { trail_id: ObjectID, owner: IotaAddress, new_config: LockingConfig, + selected_capability_id: Option, ) -> Result where C: CoreClientReadOnly + OptionalSync, @@ -31,6 +32,7 @@ impl LockingOps { trail_id, owner, Permission::UpdateLockingConfig, + selected_capability_id, "update_locking_config", |ptb, _| { let config = new_config.to_ptb(ptb, client.package_id(), tf_components_package_id)?; @@ -47,6 +49,7 @@ impl LockingOps { trail_id: ObjectID, owner: IotaAddress, new_delete_record_window: LockingWindow, + selected_capability_id: Option, ) -> Result where C: CoreClientReadOnly + OptionalSync, @@ -56,6 +59,7 @@ impl LockingOps { trail_id, owner, Permission::UpdateLockingConfigForDeleteRecord, + selected_capability_id, "update_delete_record_window", |ptb, _| { let window = new_delete_record_window.to_ptb(ptb, client.package_id())?; @@ -72,6 +76,7 @@ impl LockingOps { trail_id: ObjectID, owner: IotaAddress, new_delete_trail_lock: TimeLock, + selected_capability_id: Option, ) -> Result where C: CoreClientReadOnly + OptionalSync, @@ -84,6 +89,7 @@ impl LockingOps { trail_id, owner, Permission::UpdateLockingConfigForDeleteTrail, + selected_capability_id, "update_delete_trail_lock", |ptb, _| { let delete_trail_lock = new_delete_trail_lock.to_ptb(ptb, tf_components_package_id)?; @@ -100,6 +106,7 @@ impl LockingOps { trail_id: ObjectID, owner: IotaAddress, new_write_lock: TimeLock, + selected_capability_id: Option, ) -> Result where C: CoreClientReadOnly + OptionalSync, @@ -112,6 +119,7 @@ impl LockingOps { trail_id, owner, Permission::UpdateLockingConfigForWrite, + selected_capability_id, "update_write_lock", |ptb, _| { let write_lock = new_write_lock.to_ptb(ptb, tf_components_package_id)?; diff --git a/audit-trail-rs/src/core/locking/transactions.rs b/audit-trail-rs/src/core/locking/transactions.rs index b3117d84..a1690eb0 100644 --- a/audit-trail-rs/src/core/locking/transactions.rs +++ b/audit-trail-rs/src/core/locking/transactions.rs @@ -19,15 +19,22 @@ pub struct UpdateLockingConfig { trail_id: ObjectID, owner: IotaAddress, config: LockingConfig, + selected_capability_id: Option, cached_ptb: OnceCell, } impl UpdateLockingConfig { - pub fn new(trail_id: ObjectID, owner: IotaAddress, config: LockingConfig) -> Self { + pub fn new( + trail_id: ObjectID, + owner: IotaAddress, + config: LockingConfig, + selected_capability_id: Option, + ) -> Self { Self { trail_id, owner, config, + selected_capability_id, cached_ptb: OnceCell::new(), } } @@ -36,7 +43,14 @@ impl UpdateLockingConfig { where C: CoreClientReadOnly + OptionalSync, { - LockingOps::update_locking_config(client, self.trail_id, self.owner, self.config.clone()).await + LockingOps::update_locking_config( + client, + self.trail_id, + self.owner, + self.config.clone(), + self.selected_capability_id, + ) + .await } } @@ -66,15 +80,22 @@ pub struct UpdateDeleteRecordWindow { trail_id: ObjectID, owner: IotaAddress, window: LockingWindow, + selected_capability_id: Option, cached_ptb: OnceCell, } impl UpdateDeleteRecordWindow { - pub fn new(trail_id: ObjectID, owner: IotaAddress, window: LockingWindow) -> Self { + pub fn new( + trail_id: ObjectID, + owner: IotaAddress, + window: LockingWindow, + selected_capability_id: Option, + ) -> Self { Self { trail_id, owner, window, + selected_capability_id, cached_ptb: OnceCell::new(), } } @@ -83,7 +104,14 @@ impl UpdateDeleteRecordWindow { where C: CoreClientReadOnly + OptionalSync, { - LockingOps::update_delete_record_window(client, self.trail_id, self.owner, self.window.clone()).await + LockingOps::update_delete_record_window( + client, + self.trail_id, + self.owner, + self.window.clone(), + self.selected_capability_id, + ) + .await } } @@ -113,15 +141,22 @@ pub struct UpdateDeleteTrailLock { trail_id: ObjectID, owner: IotaAddress, lock: TimeLock, + selected_capability_id: Option, cached_ptb: OnceCell, } impl UpdateDeleteTrailLock { - pub fn new(trail_id: ObjectID, owner: IotaAddress, lock: TimeLock) -> Self { + pub fn new( + trail_id: ObjectID, + owner: IotaAddress, + lock: TimeLock, + selected_capability_id: Option, + ) -> Self { Self { trail_id, owner, lock, + selected_capability_id, cached_ptb: OnceCell::new(), } } @@ -130,7 +165,14 @@ impl UpdateDeleteTrailLock { where C: CoreClientReadOnly + OptionalSync, { - LockingOps::update_delete_trail_lock(client, self.trail_id, self.owner, self.lock.clone()).await + LockingOps::update_delete_trail_lock( + client, + self.trail_id, + self.owner, + self.lock.clone(), + self.selected_capability_id, + ) + .await } } @@ -160,15 +202,22 @@ pub struct UpdateWriteLock { trail_id: ObjectID, owner: IotaAddress, lock: TimeLock, + selected_capability_id: Option, cached_ptb: OnceCell, } impl UpdateWriteLock { - pub fn new(trail_id: ObjectID, owner: IotaAddress, lock: TimeLock) -> Self { + pub fn new( + trail_id: ObjectID, + owner: IotaAddress, + lock: TimeLock, + selected_capability_id: Option, + ) -> Self { Self { trail_id, owner, lock, + selected_capability_id, cached_ptb: OnceCell::new(), } } @@ -177,7 +226,14 @@ impl UpdateWriteLock { where C: CoreClientReadOnly + OptionalSync, { - LockingOps::update_write_lock(client, self.trail_id, self.owner, self.lock.clone()).await + LockingOps::update_write_lock( + client, + self.trail_id, + self.owner, + self.lock.clone(), + self.selected_capability_id, + ) + .await } } diff --git a/audit-trail-rs/src/core/records/mod.rs b/audit-trail-rs/src/core/records/mod.rs index 53a93d4c..02b117ce 100644 --- a/audit-trail-rs/src/core/records/mod.rs +++ b/audit-trail-rs/src/core/records/mod.rs @@ -33,18 +33,26 @@ const MAX_LIST_PAGE_LIMIT: usize = 1_000; pub struct TrailRecords<'a, C, D = Data> { pub(crate) client: &'a C, pub(crate) trail_id: ObjectID, + pub(crate) selected_capability_id: Option, pub(crate) _phantom: std::marker::PhantomData, } impl<'a, C, D> TrailRecords<'a, C, D> { - pub(crate) fn new(client: &'a C, trail_id: ObjectID) -> Self { + pub(crate) fn new(client: &'a C, trail_id: ObjectID, selected_capability_id: Option) -> Self { Self { client, trail_id, + selected_capability_id, _phantom: std::marker::PhantomData, } } + /// Uses the provided capability as the auth capability for subsequent write operations. + pub fn using_capability(mut self, capability_id: ObjectID) -> Self { + self.selected_capability_id = Some(capability_id); + self + } + pub async fn get(&self, sequence_number: u64) -> Result, Error> where C: AuditTrailReadOnly, @@ -61,7 +69,14 @@ impl<'a, C, D> TrailRecords<'a, C, D> { D: Into, { let owner = self.client.sender_address(); - TransactionBuilder::new(AddRecord::new(self.trail_id, owner, data.into(), metadata, tag)) + TransactionBuilder::new(AddRecord::new( + self.trail_id, + owner, + data.into(), + metadata, + tag, + self.selected_capability_id, + )) } pub fn delete(&self, sequence_number: u64) -> TransactionBuilder @@ -70,7 +85,12 @@ impl<'a, C, D> TrailRecords<'a, C, D> { S: Signer + OptionalSync, { let owner = self.client.sender_address(); - TransactionBuilder::new(DeleteRecord::new(self.trail_id, owner, sequence_number)) + TransactionBuilder::new(DeleteRecord::new( + self.trail_id, + owner, + sequence_number, + self.selected_capability_id, + )) } pub fn delete_records_batch(&self, limit: u64) -> TransactionBuilder @@ -79,7 +99,12 @@ impl<'a, C, D> TrailRecords<'a, C, D> { S: Signer + OptionalSync, { let owner = self.client.sender_address(); - TransactionBuilder::new(DeleteRecordsBatch::new(self.trail_id, owner, limit)) + TransactionBuilder::new(DeleteRecordsBatch::new( + self.trail_id, + owner, + limit, + self.selected_capability_id, + )) } pub async fn correct(&self, _replaces: Vec, _data: D, _metadata: Option) -> Result<(), Error> diff --git a/audit-trail-rs/src/core/records/operations.rs b/audit-trail-rs/src/core/records/operations.rs index 84df9a9c..50412ac1 100644 --- a/audit-trail-rs/src/core/records/operations.rs +++ b/audit-trail-rs/src/core/records/operations.rs @@ -6,8 +6,9 @@ use iota_interaction::types::base_types::{IotaAddress, ObjectID}; use iota_interaction::types::transaction::ProgrammableTransaction; use product_common::core_client::CoreClientReadOnly; -use crate::core::internal::{capability, trail as trail_reader, tx}; -use crate::core::types::{Data, OnChainAuditTrail, Permission}; +use crate::core::internal::capability::find_capable_cap_for_tag; +use crate::core::internal::{trail as trail_reader, tx}; +use crate::core::types::{Data, Permission}; use crate::error::Error; pub(super) struct RecordsOps; @@ -20,6 +21,7 @@ impl RecordsOps { data: Data, record_metadata: Option, record_tag: Option, + selected_capability_id: Option, ) -> Result where C: CoreClientReadOnly + OptionalSync, @@ -32,7 +34,11 @@ impl RecordsOps { "record tag '{tag}' is not defined for trail {trail_id}" ))); } - let cap_ref = find_capable_cap_for_tag(client, owner, trail_id, &trail, &tag).await?; + let cap_ref = if let Some(capability_id) = selected_capability_id { + tx::get_object_ref_by_id(client, &capability_id).await? + } else { + find_capable_cap_for_tag(client, owner, trail_id, &trail, &tag).await? + }; tx::build_trail_transaction_with_cap_ref(client, trail_id, cap_ref, "add_record", |ptb, trail_tag| { data.ensure_matches_tag(trail_tag, package_id)?; @@ -50,6 +56,7 @@ impl RecordsOps { trail_id, owner, Permission::AddRecord, + selected_capability_id, "add_record", |ptb, trail_tag| { data.ensure_matches_tag(trail_tag, package_id)?; @@ -70,6 +77,7 @@ impl RecordsOps { trail_id: ObjectID, owner: IotaAddress, sequence_number: u64, + selected_capability_id: Option, ) -> Result where C: CoreClientReadOnly + OptionalSync, @@ -79,6 +87,7 @@ impl RecordsOps { trail_id, owner, Permission::DeleteRecord, + selected_capability_id, "delete_record", |ptb, _| { let seq = tx::ptb_pure(ptb, "sequence_number", sequence_number)?; @@ -94,6 +103,7 @@ impl RecordsOps { trail_id: ObjectID, owner: IotaAddress, limit: u64, + selected_capability_id: Option, ) -> Result where C: CoreClientReadOnly + OptionalSync, @@ -103,6 +113,7 @@ impl RecordsOps { trail_id, owner, Permission::DeleteAllRecords, + selected_capability_id, "delete_records_batch", |ptb, _| { let limit_arg = tx::ptb_pure(ptb, "limit", limit)?; @@ -135,39 +146,3 @@ impl RecordsOps { tx::build_read_only_transaction(client, trail_id, "record_count", |_| Ok(vec![])).await } } - -async fn find_capable_cap_for_tag( - client: &C, - owner: IotaAddress, - trail_id: ObjectID, - trail: &OnChainAuditTrail, - tag: &str, -) -> Result -where - C: CoreClientReadOnly + OptionalSync, -{ - let valid_roles = trail - .roles - .roles - .iter() - .filter(|(_, role)| { - role.permissions.contains(&Permission::AddRecord) - && role.data.as_ref().is_some_and(|record_tags| record_tags.allows(tag)) - }) - .map(|(name, _)| name.clone()) - .collect::>(); - - let cap = capability::find_owned_capability(client, owner, trail, |cap| { - cap.target_key == trail_id && valid_roles.contains(&cap.role) - }) - .await? - .ok_or_else(|| { - Error::InvalidArgument(format!( - "no capability with {:?} permission and record tag '{tag}' found for owner {owner} and trail {trail_id}", - Permission::AddRecord - )) - })?; - - let object_id = *cap.id.object_id(); - tx::get_object_ref_by_id(client, &object_id).await -} diff --git a/audit-trail-rs/src/core/records/transactions.rs b/audit-trail-rs/src/core/records/transactions.rs index 74007aa2..a09fed4e 100644 --- a/audit-trail-rs/src/core/records/transactions.rs +++ b/audit-trail-rs/src/core/records/transactions.rs @@ -23,6 +23,7 @@ pub struct AddRecord { pub data: Data, pub metadata: Option, pub tag: Option, + pub selected_capability_id: Option, cached_ptb: OnceCell, } @@ -33,6 +34,7 @@ impl AddRecord { data: Data, metadata: Option, tag: Option, + selected_capability_id: Option, ) -> Self { Self { trail_id, @@ -40,6 +42,7 @@ impl AddRecord { data, metadata, tag, + selected_capability_id, cached_ptb: OnceCell::new(), } } @@ -55,6 +58,7 @@ impl AddRecord { self.data.clone(), self.metadata.clone(), self.tag.clone(), + self.selected_capability_id, ) .await } @@ -106,15 +110,22 @@ pub struct DeleteRecord { pub trail_id: ObjectID, pub owner: IotaAddress, pub sequence_number: u64, + pub selected_capability_id: Option, cached_ptb: OnceCell, } impl DeleteRecord { - pub fn new(trail_id: ObjectID, owner: IotaAddress, sequence_number: u64) -> Self { + pub fn new( + trail_id: ObjectID, + owner: IotaAddress, + sequence_number: u64, + selected_capability_id: Option, + ) -> Self { Self { trail_id, owner, sequence_number, + selected_capability_id, cached_ptb: OnceCell::new(), } } @@ -123,7 +134,14 @@ impl DeleteRecord { where C: CoreClientReadOnly + OptionalSync, { - RecordsOps::delete_record(client, self.trail_id, self.owner, self.sequence_number).await + RecordsOps::delete_record( + client, + self.trail_id, + self.owner, + self.sequence_number, + self.selected_capability_id, + ) + .await } } @@ -173,15 +191,17 @@ pub struct DeleteRecordsBatch { pub trail_id: ObjectID, pub owner: IotaAddress, pub limit: u64, + pub selected_capability_id: Option, cached_ptb: OnceCell, } impl DeleteRecordsBatch { - pub fn new(trail_id: ObjectID, owner: IotaAddress, limit: u64) -> Self { + pub fn new(trail_id: ObjectID, owner: IotaAddress, limit: u64, selected_capability_id: Option) -> Self { Self { trail_id, owner, limit, + selected_capability_id, cached_ptb: OnceCell::new(), } } @@ -190,7 +210,14 @@ impl DeleteRecordsBatch { where C: CoreClientReadOnly + OptionalSync, { - RecordsOps::delete_records_batch(client, self.trail_id, self.owner, self.limit).await + RecordsOps::delete_records_batch( + client, + self.trail_id, + self.owner, + self.limit, + self.selected_capability_id, + ) + .await } } diff --git a/audit-trail-rs/src/core/tags/mod.rs b/audit-trail-rs/src/core/tags/mod.rs index 3049b2f5..2d51a943 100644 --- a/audit-trail-rs/src/core/tags/mod.rs +++ b/audit-trail-rs/src/core/tags/mod.rs @@ -18,11 +18,22 @@ pub use transactions::{AddRecordTag, RemoveRecordTag}; pub struct TrailTags<'a, C> { pub(crate) client: &'a C, pub(crate) trail_id: ObjectID, + pub(crate) selected_capability_id: Option, } impl<'a, C> TrailTags<'a, C> { - pub(crate) fn new(client: &'a C, trail_id: ObjectID) -> Self { - Self { client, trail_id } + pub(crate) fn new(client: &'a C, trail_id: ObjectID, selected_capability_id: Option) -> Self { + Self { + client, + trail_id, + selected_capability_id, + } + } + + /// Uses the provided capability as the auth capability for subsequent write operations. + pub fn using_capability(mut self, capability_id: ObjectID) -> Self { + self.selected_capability_id = Some(capability_id); + self } /// Adds a tag to the trail-owned record-tag registry. @@ -32,7 +43,12 @@ impl<'a, C> TrailTags<'a, C> { S: Signer + OptionalSync, { let owner = self.client.sender_address(); - TransactionBuilder::new(AddRecordTag::new(self.trail_id, owner, tag.into())) + TransactionBuilder::new(AddRecordTag::new( + self.trail_id, + owner, + tag.into(), + self.selected_capability_id, + )) } /// Removes a tag from the trail-owned record-tag registry. @@ -42,6 +58,11 @@ impl<'a, C> TrailTags<'a, C> { S: Signer + OptionalSync, { let owner = self.client.sender_address(); - TransactionBuilder::new(RemoveRecordTag::new(self.trail_id, owner, tag.into())) + TransactionBuilder::new(RemoveRecordTag::new( + self.trail_id, + owner, + tag.into(), + self.selected_capability_id, + )) } } diff --git a/audit-trail-rs/src/core/tags/operations.rs b/audit-trail-rs/src/core/tags/operations.rs index 57b8b7a3..36bc1980 100644 --- a/audit-trail-rs/src/core/tags/operations.rs +++ b/audit-trail-rs/src/core/tags/operations.rs @@ -18,6 +18,7 @@ impl TagsOps { trail_id: ObjectID, owner: IotaAddress, tag: String, + selected_capability_id: Option, ) -> Result where C: CoreClientReadOnly + OptionalSync, @@ -27,6 +28,7 @@ impl TagsOps { trail_id, owner, Permission::AddRecordTags, + selected_capability_id, "add_record_tag", |ptb, _| { let tag_arg = tx::ptb_pure(ptb, "tag", tag)?; @@ -42,6 +44,7 @@ impl TagsOps { trail_id: ObjectID, owner: IotaAddress, tag: String, + selected_capability_id: Option, ) -> Result where C: CoreClientReadOnly + OptionalSync, @@ -51,6 +54,7 @@ impl TagsOps { trail_id, owner, Permission::DeleteRecordTags, + selected_capability_id, "remove_record_tag", |ptb, _| { let tag_arg = tx::ptb_pure(ptb, "tag", tag)?; diff --git a/audit-trail-rs/src/core/tags/transactions.rs b/audit-trail-rs/src/core/tags/transactions.rs index 7f310926..1c4727de 100644 --- a/audit-trail-rs/src/core/tags/transactions.rs +++ b/audit-trail-rs/src/core/tags/transactions.rs @@ -18,15 +18,17 @@ pub struct AddRecordTag { trail_id: ObjectID, owner: IotaAddress, tag: String, + selected_capability_id: Option, cached_ptb: OnceCell, } impl AddRecordTag { - pub fn new(trail_id: ObjectID, owner: IotaAddress, tag: String) -> Self { + pub fn new(trail_id: ObjectID, owner: IotaAddress, tag: String, selected_capability_id: Option) -> Self { Self { trail_id, owner, tag, + selected_capability_id, cached_ptb: OnceCell::new(), } } @@ -35,7 +37,14 @@ impl AddRecordTag { where C: CoreClientReadOnly + OptionalSync, { - TagsOps::add_record_tag(client, self.trail_id, self.owner, self.tag.clone()).await + TagsOps::add_record_tag( + client, + self.trail_id, + self.owner, + self.tag.clone(), + self.selected_capability_id, + ) + .await } } @@ -65,15 +74,17 @@ pub struct RemoveRecordTag { trail_id: ObjectID, owner: IotaAddress, tag: String, + selected_capability_id: Option, cached_ptb: OnceCell, } impl RemoveRecordTag { - pub fn new(trail_id: ObjectID, owner: IotaAddress, tag: String) -> Self { + pub fn new(trail_id: ObjectID, owner: IotaAddress, tag: String, selected_capability_id: Option) -> Self { Self { trail_id, owner, tag, + selected_capability_id, cached_ptb: OnceCell::new(), } } @@ -82,7 +93,14 @@ impl RemoveRecordTag { where C: CoreClientReadOnly + OptionalSync, { - TagsOps::remove_record_tag(client, self.trail_id, self.owner, self.tag.clone()).await + TagsOps::remove_record_tag( + client, + self.trail_id, + self.owner, + self.tag.clone(), + self.selected_capability_id, + ) + .await } } diff --git a/audit-trail-rs/src/core/trail.rs b/audit-trail-rs/src/core/trail.rs index 00c84327..593c3ac2 100644 --- a/audit-trail-rs/src/core/trail.rs +++ b/audit-trail-rs/src/core/trail.rs @@ -40,11 +40,22 @@ pub trait AuditTrailFull: AuditTrailReadOnly {} pub struct AuditTrailHandle<'a, C> { pub(crate) client: &'a C, pub(crate) trail_id: ObjectID, + pub(crate) selected_capability_id: Option, } impl<'a, C> AuditTrailHandle<'a, C> { pub(crate) fn new(client: &'a C, trail_id: ObjectID) -> Self { - Self { client, trail_id } + Self { + client, + trail_id, + selected_capability_id: None, + } + } + + /// Uses the provided capability as the auth capability for subsequent write operations. + pub fn using_capability(mut self, capability_id: ObjectID) -> Self { + self.selected_capability_id = Some(capability_id); + self } /// Loads the full on-chain audit trail object. @@ -62,7 +73,12 @@ impl<'a, C> AuditTrailHandle<'a, C> { S: Signer + OptionalSync, { let owner = self.client.sender_address(); - TransactionBuilder::new(UpdateMetadata::new(self.trail_id, owner, metadata)) + TransactionBuilder::new(UpdateMetadata::new( + self.trail_id, + owner, + metadata, + self.selected_capability_id, + )) } /// Migrates the trail to the latest package version. @@ -72,7 +88,7 @@ impl<'a, C> AuditTrailHandle<'a, C> { S: Signer + OptionalSync, { let owner = self.client.sender_address(); - TransactionBuilder::new(Migrate::new(self.trail_id, owner)) + TransactionBuilder::new(Migrate::new(self.trail_id, owner, self.selected_capability_id)) } /// Deletes the audit trail object. @@ -84,22 +100,22 @@ impl<'a, C> AuditTrailHandle<'a, C> { S: Signer + OptionalSync, { let owner = self.client.sender_address(); - TransactionBuilder::new(DeleteAuditTrail::new(self.trail_id, owner)) + TransactionBuilder::new(DeleteAuditTrail::new(self.trail_id, owner, self.selected_capability_id)) } pub fn records(&self) -> TrailRecords<'a, C, Data> { - TrailRecords::new(self.client, self.trail_id) + TrailRecords::new(self.client, self.trail_id, self.selected_capability_id) } pub fn locking(&self) -> TrailLocking<'a, C> { - TrailLocking::new(self.client, self.trail_id) + TrailLocking::new(self.client, self.trail_id, self.selected_capability_id) } pub fn access(&self) -> TrailAccess<'a, C> { - TrailAccess::new(self.client, self.trail_id) + TrailAccess::new(self.client, self.trail_id, self.selected_capability_id) } pub fn tags(&self) -> TrailTags<'a, C> { - TrailTags::new(self.client, self.trail_id) + TrailTags::new(self.client, self.trail_id, self.selected_capability_id) } } diff --git a/audit-trail-rs/src/core/trail/operations.rs b/audit-trail-rs/src/core/trail/operations.rs index 88db6049..3b003914 100644 --- a/audit-trail-rs/src/core/trail/operations.rs +++ b/audit-trail-rs/src/core/trail/operations.rs @@ -17,14 +17,23 @@ impl TrailOps { client: &C, trail_id: ObjectID, owner: IotaAddress, + selected_capability_id: Option, ) -> Result where C: CoreClientReadOnly + OptionalSync, { - tx::build_trail_transaction(client, trail_id, owner, Permission::Migrate, "migrate", |ptb, _| { - let clock = tx::get_clock_ref(ptb); - Ok(vec![clock]) - }) + tx::build_trail_transaction( + client, + trail_id, + owner, + Permission::Migrate, + selected_capability_id, + "migrate", + |ptb, _| { + let clock = tx::get_clock_ref(ptb); + Ok(vec![clock]) + }, + ) .await } @@ -33,6 +42,7 @@ impl TrailOps { trail_id: ObjectID, owner: IotaAddress, metadata: Option, + selected_capability_id: Option, ) -> Result where C: CoreClientReadOnly + OptionalSync, @@ -42,6 +52,7 @@ impl TrailOps { trail_id, owner, Permission::UpdateMetadata, + selected_capability_id, "update_metadata", |ptb, _| { let metadata_arg = tx::ptb_pure(ptb, "new_metadata", metadata)?; @@ -56,6 +67,7 @@ impl TrailOps { client: &C, trail_id: ObjectID, owner: IotaAddress, + selected_capability_id: Option, ) -> Result where C: CoreClientReadOnly + OptionalSync, @@ -65,6 +77,7 @@ impl TrailOps { trail_id, owner, Permission::DeleteAuditTrail, + selected_capability_id, "delete_audit_trail", |ptb, _| { let clock = tx::get_clock_ref(ptb); diff --git a/audit-trail-rs/src/core/trail/transactions.rs b/audit-trail-rs/src/core/trail/transactions.rs index 4f12359c..47859145 100644 --- a/audit-trail-rs/src/core/trail/transactions.rs +++ b/audit-trail-rs/src/core/trail/transactions.rs @@ -18,14 +18,16 @@ use crate::error::Error; pub struct Migrate { trail_id: ObjectID, owner: IotaAddress, + selected_capability_id: Option, cached_ptb: OnceCell, } impl Migrate { - pub fn new(trail_id: ObjectID, owner: IotaAddress) -> Self { + pub fn new(trail_id: ObjectID, owner: IotaAddress, selected_capability_id: Option) -> Self { Self { trail_id, owner, + selected_capability_id, cached_ptb: OnceCell::new(), } } @@ -34,7 +36,7 @@ impl Migrate { where C: CoreClientReadOnly + OptionalSync, { - TrailOps::migrate(client, self.trail_id, self.owner).await + TrailOps::migrate(client, self.trail_id, self.owner, self.selected_capability_id).await } } @@ -64,15 +66,22 @@ pub struct UpdateMetadata { trail_id: ObjectID, owner: IotaAddress, metadata: Option, + selected_capability_id: Option, cached_ptb: OnceCell, } impl UpdateMetadata { - pub fn new(trail_id: ObjectID, owner: IotaAddress, metadata: Option) -> Self { + pub fn new( + trail_id: ObjectID, + owner: IotaAddress, + metadata: Option, + selected_capability_id: Option, + ) -> Self { Self { trail_id, owner, metadata, + selected_capability_id, cached_ptb: OnceCell::new(), } } @@ -81,7 +90,14 @@ impl UpdateMetadata { where C: CoreClientReadOnly + OptionalSync, { - TrailOps::update_metadata(client, self.trail_id, self.owner, self.metadata.clone()).await + TrailOps::update_metadata( + client, + self.trail_id, + self.owner, + self.metadata.clone(), + self.selected_capability_id, + ) + .await } } @@ -110,14 +126,16 @@ impl Transaction for UpdateMetadata { pub struct DeleteAuditTrail { trail_id: ObjectID, owner: IotaAddress, + selected_capability_id: Option, cached_ptb: OnceCell, } impl DeleteAuditTrail { - pub fn new(trail_id: ObjectID, owner: IotaAddress) -> Self { + pub fn new(trail_id: ObjectID, owner: IotaAddress, selected_capability_id: Option) -> Self { Self { trail_id, owner, + selected_capability_id, cached_ptb: OnceCell::new(), } } @@ -126,7 +144,7 @@ impl DeleteAuditTrail { where C: CoreClientReadOnly + OptionalSync, { - TrailOps::delete_audit_trail(client, self.trail_id, self.owner).await + TrailOps::delete_audit_trail(client, self.trail_id, self.owner, self.selected_capability_id).await } } diff --git a/audit-trail-rs/tests/e2e/records.rs b/audit-trail-rs/tests/e2e/records.rs index 973487ed..46bab339 100644 --- a/audit-trail-rs/tests/e2e/records.rs +++ b/audit-trail-rs/tests/e2e/records.rs @@ -1,6 +1,8 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +use std::time::{SystemTime, UNIX_EPOCH}; + use audit_trail::core::types::{ CapabilityIssueOptions, Data, InitialRecord, LockingConfig, LockingWindow, Permission, RoleTags, TimeLock, }; @@ -173,116 +175,304 @@ async fn add_tagged_record_requires_trail_defined_tag() -> anyhow::Result<()> { } #[tokio::test] -async fn add_record_skips_revoked_capability_when_valid_one_exists() -> anyhow::Result<()> { - let admin = get_funded_test_client().await?; - let writer = get_funded_test_client().await?; - let trail_id = admin.create_test_trail(Data::text("records-revoked-selector")).await?; - let records = writer.trail(trail_id).records(); +async fn add_record_selector_skips_revoked_capability_when_valid_one_exists() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + + // Untagged record flow. + let trail_id = client.create_test_trail(Data::text("records-revoked-selector")).await?; + let records = client.trail(trail_id).records(); let role_name = "RecordWriter"; - admin + client .create_role(trail_id, role_name, [Permission::AddRecord], None) .await?; - let stale_cap = admin - .issue_cap( - trail_id, - role_name, - CapabilityIssueOptions { - issued_to: Some(writer.sender_address()), - ..CapabilityIssueOptions::default() - }, + + // Revoked capability. + let revoked_cap = client + .issue_cap(trail_id, role_name, CapabilityIssueOptions::default()) + .await?; + client + .trail(trail_id) + .access() + .revoke_capability(revoked_cap.capability_id, revoked_cap.valid_until) + .build_and_execute(&client) + .await?; + + // Valid fallback capability. + client + .issue_cap(trail_id, role_name, CapabilityIssueOptions::default()) + .await?; + + let added = records + .add(Data::text("writer record"), None, None) + .build_and_execute(&client) + .await? + .output; + + assert_eq!(added.sequence_number, 1); + assert_text_data(records.get(1).await?.data, "writer record"); + + // Tagged record flow. + let tagged_trail_id = client + .create_test_trail_with_tags(Data::text("records-revoked-tagged"), ["finance"]) + .await?; + let tagged_records = client.trail(tagged_trail_id).records(); + let tagged_role_name = "TaggedWriter"; + + client + .create_role( + tagged_trail_id, + tagged_role_name, + [Permission::AddRecord], + Some(RoleTags::new(["finance"])), ) .await?; - admin - .trail(trail_id) + // Revoked capability. + let revoked_tagged_cap = client + .issue_cap(tagged_trail_id, tagged_role_name, CapabilityIssueOptions::default()) + .await?; + client + .trail(tagged_trail_id) .access() - .revoke_capability(stale_cap.capability_id, stale_cap.valid_until) - .build_and_execute(&admin) + .revoke_capability(revoked_tagged_cap.capability_id, revoked_tagged_cap.valid_until) + .build_and_execute(&client) + .await?; + + // Valid fallback capability. + client + .issue_cap(tagged_trail_id, tagged_role_name, CapabilityIssueOptions::default()) + .await?; + + let tagged_added = tagged_records + .add( + Data::text("finance entry"), + Some("tagged".to_string()), + Some("finance".to_string()), + ) + .build_and_execute(&client) + .await? + .output; + + assert_eq!(tagged_added.sequence_number, 1); + assert_eq!(tagged_records.get(1).await?.tag, Some("finance".to_string())); + + Ok(()) +} + +#[tokio::test] +async fn add_record_selector_skips_expired_capability_when_valid_one_exists() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let now_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + + // Untagged record flow. + let trail_id = client.create_test_trail(Data::text("records-expired-selector")).await?; + let records = client.trail(trail_id).records(); + let role_name = "RecordWriter"; + + client + .create_role(trail_id, role_name, [Permission::AddRecord], None) .await?; - admin + // Expired capability. + client .issue_cap( trail_id, role_name, CapabilityIssueOptions { - issued_to: Some(writer.sender_address()), + valid_until_ms: Some(now_ms.saturating_sub(60_000)), ..CapabilityIssueOptions::default() }, ) .await?; + // Valid fallback capability. + client + .issue_cap(trail_id, role_name, CapabilityIssueOptions::default()) + .await?; + let added = records .add(Data::text("writer record"), None, None) - .build_and_execute(&writer) + .build_and_execute(&client) .await? .output; assert_eq!(added.sequence_number, 1); assert_text_data(records.get(1).await?.data, "writer record"); - Ok(()) -} - -#[tokio::test] -async fn add_tagged_record_skips_revoked_capability_when_valid_one_exists() -> anyhow::Result<()> { - let admin = get_funded_test_client().await?; - let writer = get_funded_test_client().await?; - let trail_id = admin - .create_test_trail_with_tags(Data::text("records-revoked-tagged"), ["finance"]) + // Tagged record flow. + let tagged_trail_id = client + .create_test_trail_with_tags(Data::text("records-expired-tagged"), ["finance"]) .await?; - let records = writer.trail(trail_id).records(); - let role_name = "TaggedWriter"; - admin + let tagged_records = client.trail(tagged_trail_id).records(); + let tagged_role_name = "TaggedWriter"; + + client .create_role( - trail_id, - role_name, + tagged_trail_id, + tagged_role_name, [Permission::AddRecord], Some(RoleTags::new(["finance"])), ) .await?; - let stale_cap = admin + // Expired capability. + client + .issue_cap( + tagged_trail_id, + tagged_role_name, + CapabilityIssueOptions { + valid_until_ms: Some(now_ms.saturating_sub(60_000)), + ..CapabilityIssueOptions::default() + }, + ) + .await?; + + // Valid fallback capability. + client + .issue_cap(tagged_trail_id, tagged_role_name, CapabilityIssueOptions::default()) + .await?; + + let tagged_added = tagged_records + .add( + Data::text("finance entry"), + Some("tagged".to_string()), + Some("finance".to_string()), + ) + .build_and_execute(&client) + .await? + .output; + + assert_eq!(tagged_added.sequence_number, 1); + assert_eq!(tagged_records.get(1).await?.tag, Some("finance".to_string())); + + Ok(()) +} + +#[tokio::test] +async fn add_record_using_capability_uses_selected_capability_without_fallback() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let now_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + + // Untagged record flow. + let trail_id = client + .create_test_trail(Data::text("records-explicit-cap-selector")) + .await?; + let role_name = "RecordWriter"; + + client + .create_role(trail_id, role_name, [Permission::AddRecord], None) + .await?; + + let expired_cap = client .issue_cap( trail_id, role_name, CapabilityIssueOptions { - issued_to: Some(writer.sender_address()), + valid_until_ms: Some(now_ms.saturating_sub(60_000)), ..CapabilityIssueOptions::default() }, ) .await?; + let valid_cap = client + .issue_cap(trail_id, role_name, CapabilityIssueOptions::default()) + .await?; - admin + let denied = client .trail(trail_id) - .access() - .revoke_capability(stale_cap.capability_id, stale_cap.valid_until) - .build_and_execute(&admin) + .records() + .using_capability(expired_cap.capability_id) + .add(Data::text("should fail"), None, None) + .build_and_execute(&client) + .await; + + assert!( + denied.is_err(), + "explicit capability selection should not fall back when the chosen capability is expired" + ); + + let added = client + .trail(trail_id) + .records() + .using_capability(valid_cap.capability_id) + .add(Data::text("writer record"), None, None) + .build_and_execute(&client) + .await? + .output; + + assert_eq!(added.sequence_number, 1); + assert_text_data(client.trail(trail_id).records().get(1).await?.data, "writer record"); + + // Tagged record flow. + let tagged_trail_id = client + .create_test_trail_with_tags(Data::text("records-explicit-cap-tagged"), ["finance"]) + .await?; + let tagged_role_name = "TaggedWriter"; + + client + .create_role( + tagged_trail_id, + tagged_role_name, + [Permission::AddRecord], + Some(RoleTags::new(["finance"])), + ) .await?; - admin + let expired_tagged_cap = client .issue_cap( - trail_id, - role_name, + tagged_trail_id, + tagged_role_name, CapabilityIssueOptions { - issued_to: Some(writer.sender_address()), + valid_until_ms: Some(now_ms.saturating_sub(60_000)), ..CapabilityIssueOptions::default() }, ) .await?; + let valid_tagged_cap = client + .issue_cap(tagged_trail_id, tagged_role_name, CapabilityIssueOptions::default()) + .await?; - let added = records + let tagged_denied = client + .trail(tagged_trail_id) + .records() + .using_capability(expired_tagged_cap.capability_id) + .add( + Data::text("should fail"), + Some("tagged".to_string()), + Some("finance".to_string()), + ) + .build_and_execute(&client) + .await; + + assert!( + tagged_denied.is_err(), + "tagged writes should also use the explicitly selected capability without fallback" + ); + + let tagged_added = client + .trail(tagged_trail_id) + .records() + .using_capability(valid_tagged_cap.capability_id) .add( Data::text("finance entry"), Some("tagged".to_string()), Some("finance".to_string()), ) - .build_and_execute(&writer) + .build_and_execute(&client) .await? .output; - assert_eq!(added.sequence_number, 1); - assert_eq!(records.get(1).await?.tag, Some("finance".to_string())); + assert_eq!(tagged_added.sequence_number, 1); + assert_eq!( + client.trail(tagged_trail_id).records().get(1).await?.tag, + Some("finance".to_string()) + ); Ok(()) } From d439de6e1c95eadc4a4d3705316724cf11824972 Mon Sep 17 00:00:00 2001 From: Yasir Date: Fri, 10 Apr 2026 15:17:30 +0300 Subject: [PATCH 152/189] =?UTF-8?q?fix:=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20multi-actor=20examples=20and=20doc=20polish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Introduce named actor clients across all audit trail examples so each client signs with its own key and capabilities are bound to specific addresses (issued_to) rather than left unrestricted - Add //! ## Actors module-level doc to every example describing each actor's role and permissions - Refactor 01_customs_clearance and 02_clinical_trial to use fully distinct clients per operational role (DocsOperator, ExportBroker, Inspector, Monitor, DataSafetyBoard, etc.) - Replace plain-text document data with SHA-256 hash in customs clearance; add comment about off-chain TWIN node storage - Add section-title comments to customs clearance for easier navigation - Add capability auto-lookup comment to 09_tagged_records - Move records accessor definition ALAP in 06_delete_records - Add localnet eval-based quickstart section to WASM examples README --- .../wasm/audit_trail_wasm/examples/README.md | 9 ++ examples/audit-trail/01_create_audit_trail.rs | 45 +++--- .../audit-trail/02_add_and_read_records.rs | 42 ++++-- examples/audit-trail/03_update_metadata.rs | 55 +++++--- examples/audit-trail/04_configure_locking.rs | 89 ++++++++---- examples/audit-trail/05_manage_access.rs | 43 ++++-- examples/audit-trail/06_delete_records.rs | 39 ++++-- .../07_access_read_only_methods.rs | 45 ++++-- examples/audit-trail/08_delete_audit_trail.rs | 49 +++++-- .../audit-trail/advanced/09_tagged_records.rs | 10 ++ .../advanced/10_capability_constraints.rs | 9 ++ .../advanced/11_manage_record_tags.rs | 70 +++++++--- .../real-world/01_customs_clearance.rs | 131 ++++++++++++------ .../real-world/02_clinical_trial.rs | 120 +++++++++------- 14 files changed, 512 insertions(+), 244 deletions(-) diff --git a/bindings/wasm/audit_trail_wasm/examples/README.md b/bindings/wasm/audit_trail_wasm/examples/README.md index 338b8b42..65aec6c9 100644 --- a/bindings/wasm/audit_trail_wasm/examples/README.md +++ b/bindings/wasm/audit_trail_wasm/examples/README.md @@ -31,6 +31,15 @@ NETWORK_URL=http://127.0.0.1:9000 \ npm run example:node -- 01_create_audit_trail ``` +### Localnet + +On localnet the publish script emits the required `export` statements directly. Use `eval` to set both variables in one step (run from the `audit_trail_wasm/` directory): + +```bash +eval $(../../../audit-trail-move/scripts/publish_package.sh) +npm run example:node -- 01_create_audit_trail +``` + Available examples: ### Core diff --git a/examples/audit-trail/01_create_audit_trail.rs b/examples/audit-trail/01_create_audit_trail.rs index 2fc44d76..9d3d8cad 100644 --- a/examples/audit-trail/01_create_audit_trail.rs +++ b/examples/audit-trail/01_create_audit_trail.rs @@ -1,6 +1,13 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +//! ## Actors +//! +//! - **Admin**: Creates the trail and holds the built-in Admin capability that is +//! automatically minted on creation. +//! - **RecordAdmin**: Receives a RecordAdmin capability bound to their address. Writes +//! records in subsequent examples. + use anyhow::Result; use audit_trail::core::types::{CapabilityIssueOptions, Data, ImmutableMetadata, InitialRecord, PermissionSet}; use examples::get_funded_audit_trail_client; @@ -10,15 +17,18 @@ use product_common::core_client::CoreClient; /// 1. Create an audit trail with an initial record and metadata. /// 2. Inspect the built-in Admin role that is automatically granted to the creator. /// 3. Use the Admin capability to define a `RecordAdmin` role. -/// 4. Issue a capability for the `RecordAdmin` role. +/// 4. Issue a capability for the `RecordAdmin` role to a specific address. #[tokio::main] async fn main() -> Result<()> { println!("=== Audit Trail: Create Trail & Define Roles ===\n"); - // Create a funded client. The client's sender address becomes the initial Admin - // of any trail it creates. - let client = get_funded_audit_trail_client().await?; - println!("Client address: {}", client.sender_address()); + // `admin` creates the trail and holds the Admin capability that is automatically + // minted on creation. `record_admin` represents the actor who will later write records. + let admin = get_funded_audit_trail_client().await?; + let record_admin = get_funded_audit_trail_client().await?; + + println!("Admin address: {}", admin.sender_address()); + println!("RecordAdmin address: {}\n", record_admin.sender_address()); // ------------------------------------------------------------------------- // Step 1: Create an audit trail @@ -31,7 +41,7 @@ async fn main() -> Result<()> { // object and transfers it to the sender's address. This capability grants // full administrative control over the trail (role management, capability // issuance, tag management, etc.). - let created = client + let created = admin .create_trail() .with_trail_metadata(ImmutableMetadata::new( "Product Shipment Audit Trail".to_string(), @@ -44,7 +54,7 @@ async fn main() -> Result<()> { None, )) .finish() - .build_and_execute(&client) + .build_and_execute(&admin) .await? .output; @@ -54,7 +64,7 @@ async fn main() -> Result<()> { ); // Fetch the on-chain trail object to inspect the automatically created Admin role. - let trail = client.trail(created.trail_id).get().await?; + let trail = admin.trail(created.trail_id).get().await?; let admin_role_name = &trail.roles.initial_admin_role_name; let admin_permissions = &trail.roles.roles[admin_role_name].permissions; println!( @@ -69,12 +79,12 @@ async fn main() -> Result<()> { // PermissionSet::record_admin_permissions() grants AddRecord, DeleteRecord, // and CorrectRecord permissions. let record_admin_role = "RecordAdmin"; - let role_created = client + let role_created = admin .trail(created.trail_id) .access() .for_role(record_admin_role) .create(PermissionSet::record_admin_permissions(), None) - .build_and_execute(&client) + .build_and_execute(&admin) .await? .output; @@ -86,15 +96,18 @@ async fn main() -> Result<()> { // ------------------------------------------------------------------------- // Step 3: Issue a capability for the RecordAdmin role // ------------------------------------------------------------------------- - // A Capability object is minted on-chain and sent to the caller's address - // (or a specified `issued_to` address via CapabilityIssueOptions). - // The holder of this capability can add, delete, and correct records on the trail. - let capability = client + // A Capability object is minted on-chain and transferred to `record_admin`'s + // address. Only the holder of that address can use it to write records. + let capability = admin .trail(created.trail_id) .access() .for_role(record_admin_role) - .issue_capability(CapabilityIssueOptions::default()) - .build_and_execute(&client) + .issue_capability(CapabilityIssueOptions { + issued_to: Some(record_admin.sender_address()), + valid_from_ms: None, + valid_until_ms: None, + }) + .build_and_execute(&admin) .await? .output; diff --git a/examples/audit-trail/02_add_and_read_records.rs b/examples/audit-trail/02_add_and_read_records.rs index d2a643c2..abd2a483 100644 --- a/examples/audit-trail/02_add_and_read_records.rs +++ b/examples/audit-trail/02_add_and_read_records.rs @@ -1,6 +1,12 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +//! ## Actors +//! +//! - **Admin**: Creates the trail, defines the RecordAdmin role, and issues a capability. +//! - **RecordAdmin**: Holds the capability and writes records. Reads are also done through +//! this client to demonstrate that any address can read, but only the cap holder can write. + use anyhow::{Result, ensure}; use audit_trail::core::types::{CapabilityIssueOptions, Data, InitialRecord, PermissionSet}; use examples::get_funded_audit_trail_client; @@ -15,13 +21,18 @@ use product_common::core_client::CoreClient; async fn main() -> Result<()> { println!("=== Audit Trail: Add & Read Records ===\n"); - let client = get_funded_audit_trail_client().await?; - println!("Client address: {}", client.sender_address()); + // `admin` creates the trail and manages roles. + // `record_admin` holds the RecordAdmin capability and writes records. + let admin = get_funded_audit_trail_client().await?; + let record_admin = get_funded_audit_trail_client().await?; + + println!("Admin address: {}", admin.sender_address()); + println!("RecordAdmin address: {}\n", record_admin.sender_address()); // ------------------------------------------------------------------------- // Step 1: Create a trail with one initial record // ------------------------------------------------------------------------- - let created = client + let created = admin .create_trail() .with_initial_record(InitialRecord::new( Data::text("Trail opened"), @@ -29,32 +40,34 @@ async fn main() -> Result<()> { None, )) .finish() - .build_and_execute(&client) + .build_and_execute(&admin) .await? .output; let trail_id = created.trail_id; - let records = client.trail(trail_id).records(); - println!("Trail created: {trail_id}\n"); // ------------------------------------------------------------------------- // Step 2: Create a record-admin role and issue a capability for it // ------------------------------------------------------------------------- - client + admin .trail(trail_id) .access() .for_role("RecordAdmin") .create(PermissionSet::record_admin_permissions(), None) - .build_and_execute(&client) + .build_and_execute(&admin) .await?; - let capability = client + let capability = admin .trail(trail_id) .access() .for_role("RecordAdmin") - .issue_capability(CapabilityIssueOptions::default()) - .build_and_execute(&client) + .issue_capability(CapabilityIssueOptions { + issued_to: Some(record_admin.sender_address()), + valid_from_ms: None, + valid_until_ms: None, + }) + .build_and_execute(&admin) .await? .output; @@ -66,13 +79,16 @@ async fn main() -> Result<()> { // ------------------------------------------------------------------------- // Step 3: Append follow-up records // ------------------------------------------------------------------------- + // The client automatically finds the capability in `record_admin`'s wallet. + let records = record_admin.trail(trail_id).records(); + let first_added = records .add( Data::text("Shipment received at warehouse A"), Some("event:received".to_string()), None, ) - .build_and_execute(&client) + .build_and_execute(&record_admin) .await? .output; @@ -82,7 +98,7 @@ async fn main() -> Result<()> { Some("event:dispatched".to_string()), None, ) - .build_and_execute(&client) + .build_and_execute(&record_admin) .await? .output; diff --git a/examples/audit-trail/03_update_metadata.rs b/examples/audit-trail/03_update_metadata.rs index 90b9efac..ba1c58ed 100644 --- a/examples/audit-trail/03_update_metadata.rs +++ b/examples/audit-trail/03_update_metadata.rs @@ -1,9 +1,16 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +//! ## Actors +//! +//! - **Admin**: Creates the trail and sets up the MetadataAdmin role. +//! - **MetadataAdmin**: Holds the MetadataAdmin capability and updates the trail's mutable +//! status field. Has no record-write permissions. + use anyhow::{Result, ensure}; -use audit_trail::core::types::{Data, ImmutableMetadata, InitialRecord, PermissionSet}; +use audit_trail::core::types::{CapabilityIssueOptions, Data, ImmutableMetadata, InitialRecord, PermissionSet}; use examples::get_funded_audit_trail_client; +use product_common::core_client::CoreClient; /// Demonstrates how to: /// 1. Create a trail with immutable and updatable metadata. @@ -14,14 +21,17 @@ use examples::get_funded_audit_trail_client; async fn main() -> Result<()> { println!("=== Audit Trail: Update Metadata ===\n"); - let client = get_funded_audit_trail_client().await?; + // `admin` creates the trail and manages roles. + // `metadata_admin` holds the MetadataAdmin capability and updates the trail status. + let admin = get_funded_audit_trail_client().await?; + let metadata_admin = get_funded_audit_trail_client().await?; let immutable_metadata = ImmutableMetadata::new( "Shipment Processing".to_string(), Some("Tracks the lifecycle of a warehouse shipment".to_string()), ); - let created = client + let created = admin .create_trail() .with_trail_metadata(immutable_metadata.clone()) .with_updatable_metadata("Status: Draft") @@ -31,40 +41,45 @@ async fn main() -> Result<()> { None, )) .finish() - .build_and_execute(&client) + .build_and_execute(&admin) .await? .output; - let trail = client.trail(created.trail_id); + let trail_id = created.trail_id; - client - .trail(created.trail_id) + admin + .trail(trail_id) .access() .for_role("MetadataAdmin") .create(PermissionSet::metadata_admin_permissions(), None) - .build_and_execute(&client) + .build_and_execute(&admin) .await?; - client - .trail(created.trail_id) + admin + .trail(trail_id) .access() .for_role("MetadataAdmin") - .issue_capability(Default::default()) - .build_and_execute(&client) + .issue_capability(CapabilityIssueOptions { + issued_to: Some(metadata_admin.sender_address()), + valid_from_ms: None, + valid_until_ms: None, + }) + .build_and_execute(&admin) .await?; - let before = trail.get().await?; + let before = admin.trail(trail_id).get().await?; println!( "Before update:\n immutable = {:?}\n updatable = {:?}\n", before.immutable_metadata, before.updatable_metadata ); - trail + metadata_admin + .trail(trail_id) .update_metadata(Some("Status: In Review".to_string())) - .build_and_execute(&client) + .build_and_execute(&metadata_admin) .await?; - let after_update = trail.get().await?; + let after_update = admin.trail(trail_id).get().await?; println!( "After update:\n immutable = {:?}\n updatable = {:?}\n", after_update.immutable_metadata, after_update.updatable_metadata @@ -73,9 +88,13 @@ async fn main() -> Result<()> { ensure!(after_update.immutable_metadata == Some(immutable_metadata.clone())); ensure!(after_update.updatable_metadata.as_deref() == Some("Status: In Review")); - trail.update_metadata(None).build_and_execute(&client).await?; + metadata_admin + .trail(trail_id) + .update_metadata(None) + .build_and_execute(&metadata_admin) + .await?; - let after_clear = trail.get().await?; + let after_clear = admin.trail(trail_id).get().await?; println!( "After clear:\n immutable = {:?}\n updatable = {:?}", after_clear.immutable_metadata, after_clear.updatable_metadata diff --git a/examples/audit-trail/04_configure_locking.rs b/examples/audit-trail/04_configure_locking.rs index 931c3614..cc6fad8f 100644 --- a/examples/audit-trail/04_configure_locking.rs +++ b/examples/audit-trail/04_configure_locking.rs @@ -1,9 +1,17 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +//! ## Actors +//! +//! - **Admin**: Creates the trail and sets up the LockingAdmin and RecordAdmin roles. +//! - **LockingAdmin**: Controls write and delete locks. Holds the LockingAdmin capability. +//! - **RecordAdmin**: Writes records. Used to demonstrate that the write lock is enforced +//! per-sender, not just checked by the admin. + use anyhow::{Result, ensure}; -use audit_trail::core::types::{Data, InitialRecord, LockingWindow, PermissionSet, TimeLock}; +use audit_trail::core::types::{CapabilityIssueOptions, Data, InitialRecord, LockingWindow, PermissionSet, TimeLock}; use examples::get_funded_audit_trail_client; +use product_common::core_client::CoreClient; /// Demonstrates how to: /// 1. Delegate locking updates through a `LockingAdmin` role. @@ -14,9 +22,14 @@ use examples::get_funded_audit_trail_client; async fn main() -> Result<()> { println!("=== Audit Trail: Configure Locking ===\n"); - let client = get_funded_audit_trail_client().await?; + // `admin` creates the trail and manages roles. + // `locking_admin` controls write and delete locks. + // `record_admin` writes records. + let admin = get_funded_audit_trail_client().await?; + let locking_admin = get_funded_audit_trail_client().await?; + let record_admin = get_funded_audit_trail_client().await?; - let created = client + let created = admin .create_trail() .with_initial_record(InitialRecord::new( Data::text("Trail opened"), @@ -24,65 +37,81 @@ async fn main() -> Result<()> { None, )) .finish() - .build_and_execute(&client) + .build_and_execute(&admin) .await? .output; - let trail = client.trail(created.trail_id); + let trail_id = created.trail_id; - trail + admin + .trail(trail_id) .access() .for_role("LockingAdmin") .create(PermissionSet::locking_admin_permissions(), None) - .build_and_execute(&client) + .build_and_execute(&admin) .await?; - trail + admin + .trail(trail_id) .access() .for_role("LockingAdmin") - .issue_capability(Default::default()) - .build_and_execute(&client) + .issue_capability(CapabilityIssueOptions { + issued_to: Some(locking_admin.sender_address()), + valid_from_ms: None, + valid_until_ms: None, + }) + .build_and_execute(&admin) .await?; - trail + admin + .trail(trail_id) .access() .for_role("RecordAdmin") .create(PermissionSet::record_admin_permissions(), None) - .build_and_execute(&client) + .build_and_execute(&admin) .await?; - trail + admin + .trail(trail_id) .access() .for_role("RecordAdmin") - .issue_capability(Default::default()) - .build_and_execute(&client) + .issue_capability(CapabilityIssueOptions { + issued_to: Some(record_admin.sender_address()), + valid_from_ms: None, + valid_until_ms: None, + }) + .build_and_execute(&admin) .await?; - trail + locking_admin + .trail(trail_id) .locking() .update_write_lock(TimeLock::Infinite) - .build_and_execute(&client) + .build_and_execute(&locking_admin) .await?; - let locked = trail.get().await?; + let locked = admin.trail(trail_id).get().await?; println!("Write lock after update: {:?}\n", locked.locking_config.write_lock); ensure!(locked.locking_config.write_lock == TimeLock::Infinite); - let blocked_add = trail + let blocked_add = record_admin + .trail(trail_id) .records() .add(Data::text("This write should fail"), None, None) - .build_and_execute(&client) + .build_and_execute(&record_admin) .await; ensure!(blocked_add.is_err(), "write lock should block adding records"); - trail + locking_admin + .trail(trail_id) .locking() .update_write_lock(TimeLock::None) - .build_and_execute(&client) + .build_and_execute(&locking_admin) .await?; - let added = trail + let added = record_admin + .trail(trail_id) .records() .add(Data::text("Write lock lifted"), Some("event:resumed".to_string()), None) - .build_and_execute(&client) + .build_and_execute(&record_admin) .await? .output; @@ -91,18 +120,20 @@ async fn main() -> Result<()> { added.sequence_number ); - trail + locking_admin + .trail(trail_id) .locking() .update_delete_record_window(LockingWindow::CountBased { count: 2 }) - .build_and_execute(&client) + .build_and_execute(&locking_admin) .await?; - trail + locking_admin + .trail(trail_id) .locking() .update_delete_trail_lock(TimeLock::Infinite) - .build_and_execute(&client) + .build_and_execute(&locking_admin) .await?; - let final_state = trail.get().await?; + let final_state = admin.trail(trail_id).get().await?; println!( "Final locking config:\n delete_record_window = {:?}\n delete_trail_lock = {:?}\n write_lock = {:?}", final_state.locking_config.delete_record_window, diff --git a/examples/audit-trail/05_manage_access.rs b/examples/audit-trail/05_manage_access.rs index 7c8155de..666f50ed 100644 --- a/examples/audit-trail/05_manage_access.rs +++ b/examples/audit-trail/05_manage_access.rs @@ -1,6 +1,13 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +//! ## Actors +//! +//! - **Admin**: Creates and updates roles, issues capabilities, revokes and destroys them, +//! and finally deletes the role once it is no longer needed. +//! - **OperationsUser**: The subject of all capability issuance. Capabilities are bound to +//! this address to demonstrate that revocation immediately blocks their access. + use std::collections::HashSet; use anyhow::{Result, ensure}; @@ -17,10 +24,12 @@ use product_common::core_client::CoreClient; async fn main() -> Result<()> { println!("=== Audit Trail: Manage Access ===\n"); - let client = get_funded_audit_trail_client().await?; - let sender = client.sender_address(); + // `admin` manages roles and capability lifecycle. + // `operations_user` represents the actor who receives (and later loses) access. + let admin = get_funded_audit_trail_client().await?; + let operations_user = get_funded_audit_trail_client().await?; - let created = client + let created = admin .create_trail() .with_initial_record(audit_trail::core::types::InitialRecord::new( Data::text("Trail created"), @@ -28,16 +37,16 @@ async fn main() -> Result<()> { None, )) .finish() - .build_and_execute(&client) + .build_and_execute(&admin) .await? .output; - let trail = client.trail(created.trail_id); + let trail = admin.trail(created.trail_id); let role = trail.access().for_role("Operations"); let created_role = role .create(PermissionSet::record_admin_permissions(), None) - .build_and_execute(&client) + .build_and_execute(&admin) .await? .output; println!("Created role: {}\n", created_role.role); @@ -52,18 +61,18 @@ async fn main() -> Result<()> { let updated_role = role .update_permissions(updated_permissions.clone(), None) - .build_and_execute(&client) + .build_and_execute(&admin) .await? .output; println!("Updated role permissions: {:?}\n", updated_role.permissions.permissions); let constrained_capability = role .issue_capability(CapabilityIssueOptions { - issued_to: Some(sender), + issued_to: Some(operations_user.sender_address()), valid_from_ms: None, valid_until_ms: Some(4_102_444_800_000), }) - .build_and_execute(&client) + .build_and_execute(&admin) .await? .output; @@ -79,31 +88,35 @@ async fn main() -> Result<()> { trail .access() .revoke_capability(constrained_capability.capability_id, constrained_capability.valid_until) - .build_and_execute(&client) + .build_and_execute(&admin) .await?; println!("Revoked capability {}\n", constrained_capability.capability_id); let disposable_capability = role - .issue_capability(Default::default()) - .build_and_execute(&client) + .issue_capability(CapabilityIssueOptions { + issued_to: Some(operations_user.sender_address()), + valid_from_ms: None, + valid_until_ms: None, + }) + .build_and_execute(&admin) .await? .output; trail .access() .destroy_capability(disposable_capability.capability_id) - .build_and_execute(&client) + .build_and_execute(&admin) .await?; println!("Destroyed capability {}\n", disposable_capability.capability_id); trail .access() .cleanup_revoked_capabilities() - .build_and_execute(&client) + .build_and_execute(&admin) .await?; println!("Cleaned up revoked capability registry entries.\n"); - role.delete().build_and_execute(&client).await?; + role.delete().build_and_execute(&admin).await?; let after_delete = trail.get().await?; ensure!( diff --git a/examples/audit-trail/06_delete_records.rs b/examples/audit-trail/06_delete_records.rs index aa418788..0d6c76e9 100644 --- a/examples/audit-trail/06_delete_records.rs +++ b/examples/audit-trail/06_delete_records.rs @@ -1,11 +1,18 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +//! ## Actors +//! +//! - **Admin**: Creates the trail and sets up the RecordMaintenance role. +//! - **RecordMaintainer**: Holds the RecordMaintenance capability. Adds records and then +//! deletes them individually and in batch. + use std::collections::HashSet; use anyhow::{Result, ensure}; use audit_trail::core::types::{CapabilityIssueOptions, Data, InitialRecord, Permission, PermissionSet}; use examples::get_funded_audit_trail_client; +use product_common::core_client::CoreClient; /// Demonstrates how to: /// 1. Create records using a delegated record-maintenance role. @@ -15,9 +22,12 @@ use examples::get_funded_audit_trail_client; async fn main() -> Result<()> { println!("=== Audit Trail: Delete Records ===\n"); - let client = get_funded_audit_trail_client().await?; + // `admin` creates the trail and manages roles. + // `record_maintainer` adds and deletes records. + let admin = get_funded_audit_trail_client().await?; + let record_maintainer = get_funded_audit_trail_client().await?; - let created = client + let created = admin .create_trail() .with_initial_record(InitialRecord::new( Data::text("Initial record"), @@ -25,12 +35,11 @@ async fn main() -> Result<()> { None, )) .finish() - .build_and_execute(&client) + .build_and_execute(&admin) .await? .output; - let trail = client.trail(created.trail_id); - let records = trail.records(); + let trail = admin.trail(created.trail_id); trail .access() @@ -45,24 +54,30 @@ async fn main() -> Result<()> { }, None, ) - .build_and_execute(&client) + .build_and_execute(&admin) .await?; trail .access() .for_role("RecordMaintenance") - .issue_capability(CapabilityIssueOptions::default()) - .build_and_execute(&client) + .issue_capability(CapabilityIssueOptions { + issued_to: Some(record_maintainer.sender_address()), + valid_from_ms: None, + valid_until_ms: None, + }) + .build_and_execute(&admin) .await?; + let records = record_maintainer.trail(created.trail_id).records(); + let added_one = records .add(Data::text("Second record"), Some("event:received".to_string()), None) - .build_and_execute(&client) + .build_and_execute(&record_maintainer) .await? .output; let added_two = records .add(Data::text("Third record"), Some("event:dispatched".to_string()), None) - .build_and_execute(&client) + .build_and_execute(&record_maintainer) .await? .output; @@ -74,7 +89,7 @@ async fn main() -> Result<()> { let deleted_one = records .delete(added_one.sequence_number) - .build_and_execute(&client) + .build_and_execute(&record_maintainer) .await? .output; println!("Deleted record {}\n", deleted_one.sequence_number); @@ -87,7 +102,7 @@ async fn main() -> Result<()> { let deleted_remaining = records .delete_records_batch(10) - .build_and_execute(&client) + .build_and_execute(&record_maintainer) .await? .output; diff --git a/examples/audit-trail/07_access_read_only_methods.rs b/examples/audit-trail/07_access_read_only_methods.rs index b32d182c..05923160 100644 --- a/examples/audit-trail/07_access_read_only_methods.rs +++ b/examples/audit-trail/07_access_read_only_methods.rs @@ -1,11 +1,19 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +//! ## Actors +//! +//! - **Admin**: Creates the trail and sets up the RecordAdmin role. +//! - **RecordAdmin**: Adds one follow-up record. All subsequent operations are read-only +//! and can be performed by any address — no capability required. + use anyhow::{Result, ensure}; use audit_trail::core::types::{ - Data, ImmutableMetadata, InitialRecord, LockingConfig, LockingWindow, PermissionSet, TimeLock, + CapabilityIssueOptions, Data, ImmutableMetadata, InitialRecord, LockingConfig, LockingWindow, PermissionSet, + TimeLock, }; use examples::get_funded_audit_trail_client; +use product_common::core_client::CoreClient; /// Demonstrates how to: /// 1. Load the full on-chain trail object. @@ -16,9 +24,12 @@ use examples::get_funded_audit_trail_client; async fn main() -> Result<()> { println!("=== Audit Trail: Read-Only Inspection ===\n"); - let client = get_funded_audit_trail_client().await?; + // `admin` creates the trail and manages roles. + // `record_admin` adds the follow-up record. + let admin = get_funded_audit_trail_client().await?; + let record_admin = get_funded_audit_trail_client().await?; - let created = client + let created = admin .create_trail() .with_trail_metadata(ImmutableMetadata::new( "Operations Trail".to_string(), @@ -36,32 +47,39 @@ async fn main() -> Result<()> { None, )) .finish() - .build_and_execute(&client) + .build_and_execute(&admin) .await? .output; - let trail = client.trail(created.trail_id); + let trail_id = created.trail_id; - trail + admin + .trail(trail_id) .access() .for_role("RecordAdmin") .create(PermissionSet::record_admin_permissions(), None) - .build_and_execute(&client) + .build_and_execute(&admin) .await?; - trail + admin + .trail(trail_id) .access() .for_role("RecordAdmin") - .issue_capability(Default::default()) - .build_and_execute(&client) + .issue_capability(CapabilityIssueOptions { + issued_to: Some(record_admin.sender_address()), + valid_from_ms: None, + valid_until_ms: None, + }) + .build_and_execute(&admin) .await?; - trail + record_admin + .trail(trail_id) .records() .add(Data::text("Follow-up record"), Some("event:updated".to_string()), None) - .build_and_execute(&client) + .build_and_execute(&record_admin) .await?; - let on_chain = trail.get().await?; + let on_chain = admin.trail(trail_id).get().await?; println!( "Trail summary:\n id = {}\n creator = {}\n created_at = {}\n sequence_number = {}\n immutable_metadata = {:?}\n updatable_metadata = {:?}\n", on_chain.id.object_id(), @@ -78,6 +96,7 @@ async fn main() -> Result<()> { on_chain.locking_config ); + let trail = admin.trail(trail_id); let count = trail.records().record_count().await?; let initial_record = trail.records().get(0).await?; let first_page = trail.records().list_page(None, 10).await?; diff --git a/examples/audit-trail/08_delete_audit_trail.rs b/examples/audit-trail/08_delete_audit_trail.rs index 041d064c..73258cb8 100644 --- a/examples/audit-trail/08_delete_audit_trail.rs +++ b/examples/audit-trail/08_delete_audit_trail.rs @@ -1,11 +1,18 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +//! ## Actors +//! +//! - **Admin**: Creates the trail and sets up the MaintenanceAdmin role. +//! - **MaintenanceAdmin**: Holds delete permissions. Attempts (and fails) to delete the +//! non-empty trail, then batch-deletes all records before removing the trail itself. + use std::collections::HashSet; use anyhow::{Result, ensure}; use audit_trail::core::types::{CapabilityIssueOptions, Data, InitialRecord, Permission, PermissionSet}; use examples::get_funded_audit_trail_client; +use product_common::core_client::CoreClient; /// Demonstrates how to: /// 1. Show that a non-empty trail cannot be deleted. @@ -15,9 +22,12 @@ use examples::get_funded_audit_trail_client; async fn main() -> Result<()> { println!("=== Audit Trail: Delete Trail ===\n"); - let client = get_funded_audit_trail_client().await?; + // `admin` creates the trail and manages roles. + // `maintenance_admin` empties and deletes the trail. + let admin = get_funded_audit_trail_client().await?; + let maintenance_admin = get_funded_audit_trail_client().await?; - let created = client + let created = admin .create_trail() .with_initial_record(InitialRecord::new( Data::text("Initial record"), @@ -25,11 +35,11 @@ async fn main() -> Result<()> { None, )) .finish() - .build_and_execute(&client) + .build_and_execute(&admin) .await? .output; - let trail = client.trail(created.trail_id); + let trail = admin.trail(created.trail_id); trail .access() @@ -40,36 +50,49 @@ async fn main() -> Result<()> { }, None, ) - .build_and_execute(&client) + .build_and_execute(&admin) .await?; trail .access() .for_role("MaintenanceAdmin") - .issue_capability(CapabilityIssueOptions::default()) - .build_and_execute(&client) + .issue_capability(CapabilityIssueOptions { + issued_to: Some(maintenance_admin.sender_address()), + valid_from_ms: None, + valid_until_ms: None, + }) + .build_and_execute(&admin) .await?; - let delete_while_non_empty = trail.delete_audit_trail().build_and_execute(&client).await; + let maintenance_trail = maintenance_admin.trail(created.trail_id); + + let delete_while_non_empty = maintenance_trail.delete_audit_trail().build_and_execute(&maintenance_admin).await; ensure!(delete_while_non_empty.is_err(), "a trail must be empty before deletion"); println!("Deleting the non-empty trail failed as expected.\n"); - let deleted_records = trail + let deleted_records = maintenance_trail .records() .delete_records_batch(10) - .build_and_execute(&client) + .build_and_execute(&maintenance_admin) .await? .output; println!("Deleted {deleted_records} record(s) before trail removal.\n"); - ensure!(trail.records().record_count().await? == 0); + ensure!(maintenance_trail.records().record_count().await? == 0); - let deleted_trail = trail.delete_audit_trail().build_and_execute(&client).await?.output; + let deleted_trail = maintenance_trail + .delete_audit_trail() + .build_and_execute(&maintenance_admin) + .await? + .output; println!( "Trail deleted:\n trail_id = {}\n timestamp = {}", deleted_trail.trail_id, deleted_trail.timestamp ); - ensure!(trail.get().await.is_err(), "deleted trail should no longer be readable"); + ensure!( + maintenance_trail.get().await.is_err(), + "deleted trail should no longer be readable" + ); Ok(()) } diff --git a/examples/audit-trail/advanced/09_tagged_records.rs b/examples/audit-trail/advanced/09_tagged_records.rs index 906f2711..c8238a16 100644 --- a/examples/audit-trail/advanced/09_tagged_records.rs +++ b/examples/audit-trail/advanced/09_tagged_records.rs @@ -1,6 +1,13 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +//! ## Actors +//! +//! - **Admin**: Creates the trail, defines the FinanceWriter role restricted to the +//! `finance` tag, and issues a capability bound to `finance_writer`'s address. +//! - **FinanceWriter**: Holds the address-bound capability. Can add `finance`-tagged +//! records but is blocked from writing `legal`-tagged records. + use anyhow::{Result, ensure}; use audit_trail::core::types::{CapabilityIssueOptions, Data, InitialRecord, Permission, RoleTags}; use examples::get_funded_audit_trail_client; @@ -65,6 +72,9 @@ async fn main() -> Result<()> { finance_writer.sender_address() ); + // The client automatically scans `finance_writer`'s wallet for a capability object that + // targets this trail and carries the required permission. No explicit capability ID is + // needed — the lookup happens in the background on every operation. let finance_records = finance_writer.trail(trail_id).records(); let added = finance_records diff --git a/examples/audit-trail/advanced/10_capability_constraints.rs b/examples/audit-trail/advanced/10_capability_constraints.rs index f2e6a968..69c1630e 100644 --- a/examples/audit-trail/advanced/10_capability_constraints.rs +++ b/examples/audit-trail/advanced/10_capability_constraints.rs @@ -1,6 +1,15 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +//! ## Actors +//! +//! - **Admin**: Creates the trail, defines the RecordAdmin role, and issues a capability +//! bound specifically to `intended_writer`'s address. Also performs revocation. +//! - **IntendedWriter**: The authorised holder. Writes a record successfully before +//! revocation, then is blocked after the capability is revoked. +//! - **WrongWriter**: An unauthorised actor who attempts to use the address-bound capability. +//! All write attempts are rejected by the Move contract. + use anyhow::{Result, ensure}; use audit_trail::core::types::{CapabilityIssueOptions, Data, InitialRecord, PermissionSet}; use examples::get_funded_audit_trail_client; diff --git a/examples/audit-trail/advanced/11_manage_record_tags.rs b/examples/audit-trail/advanced/11_manage_record_tags.rs index a4612dfb..6194dba1 100644 --- a/examples/audit-trail/advanced/11_manage_record_tags.rs +++ b/examples/audit-trail/advanced/11_manage_record_tags.rs @@ -1,9 +1,19 @@ // Copyright 2020-2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +//! ## Actors +//! +//! - **Admin**: Creates the trail and manages roles. +//! - **TagAdmin**: Holds the TagAdmin capability. Adds and removes entries from the trail's +//! tag registry. +//! - **FinanceWriter**: Holds a `finance`-scoped RecordAdmin capability. Writes a +//! `finance`-tagged record that keeps the `finance` tag in use and therefore +//! unremovable. + use anyhow::{Result, ensure}; use audit_trail::core::types::{CapabilityIssueOptions, Data, InitialRecord, PermissionSet, RoleTags}; use examples::get_funded_audit_trail_client; +use product_common::core_client::CoreClient; /// Demonstrates how to: /// 1. Delegate record-tag registry management to a `TagAdmin` role. @@ -13,70 +23,88 @@ use examples::get_funded_audit_trail_client; async fn main() -> Result<()> { println!("=== Audit Trail Advanced: Manage Record Tags ===\n"); - let client = get_funded_audit_trail_client().await?; + // `admin` creates the trail and manages roles. + // `tag_admin` adds and removes tags from the registry. + // `finance_writer` holds a tag-scoped capability and writes finance records. + let admin = get_funded_audit_trail_client().await?; + let tag_admin = get_funded_audit_trail_client().await?; + let finance_writer = get_funded_audit_trail_client().await?; - let created = client + let created = admin .create_trail() .with_record_tags(["finance"]) .with_initial_record(InitialRecord::new(Data::text("Trail created"), None, None)) .finish() - .build_and_execute(&client) + .build_and_execute(&admin) .await? .output; - let trail = client.trail(created.trail_id); + let trail_id = created.trail_id; - trail + admin + .trail(trail_id) .access() .for_role("TagAdmin") .create(PermissionSet::tag_admin_permissions(), None) - .build_and_execute(&client) + .build_and_execute(&admin) .await?; - trail + admin + .trail(trail_id) .access() .for_role("TagAdmin") - .issue_capability(Default::default()) - .build_and_execute(&client) + .issue_capability(CapabilityIssueOptions { + issued_to: Some(tag_admin.sender_address()), + valid_from_ms: None, + valid_until_ms: None, + }) + .build_and_execute(&admin) .await?; - trail.tags().add("legal").build_and_execute(&client).await?; + tag_admin.trail(trail_id).tags().add("legal").build_and_execute(&tag_admin).await?; - let after_add = trail.get().await?; + let after_add = admin.trail(trail_id).get().await?; println!("Registry after adding \"legal\": {:?}\n", after_add.tags.tag_map); ensure!(after_add.tags.contains_key("finance")); ensure!(after_add.tags.contains_key("legal")); - trail + admin + .trail(trail_id) .access() .for_role("FinanceWriter") .create( PermissionSet::record_admin_permissions(), Some(RoleTags::new(["finance"])), ) - .build_and_execute(&client) + .build_and_execute(&admin) .await?; - trail + admin + .trail(trail_id) .access() .for_role("FinanceWriter") - .issue_capability(CapabilityIssueOptions::default()) - .build_and_execute(&client) + .issue_capability(CapabilityIssueOptions { + issued_to: Some(finance_writer.sender_address()), + valid_from_ms: None, + valid_until_ms: None, + }) + .build_and_execute(&admin) .await?; - trail + finance_writer + .trail(trail_id) .records() .add(Data::text("Tagged finance entry"), None, Some("finance".to_string())) - .build_and_execute(&client) + .build_and_execute(&finance_writer) .await?; - let remove_finance = trail.tags().remove("finance").build_and_execute(&client).await; + let remove_finance = tag_admin.trail(trail_id).tags().remove("finance").build_and_execute(&tag_admin).await; ensure!( remove_finance.is_err(), "a tag referenced by a role or record must not be removable" ); - trail.tags().remove("legal").build_and_execute(&client).await?; + tag_admin.trail(trail_id).tags().remove("legal").build_and_execute(&tag_admin).await?; - let after_remove = trail.get().await?; + let after_remove = admin.trail(trail_id).get().await?; println!("Registry after removing \"legal\": {:?}\n", after_remove.tags.tag_map); ensure!(after_remove.tags.contains_key("finance")); diff --git a/examples/audit-trail/real-world/01_customs_clearance.rs b/examples/audit-trail/real-world/01_customs_clearance.rs index 5a4951e6..18e427be 100644 --- a/examples/audit-trail/real-world/01_customs_clearance.rs +++ b/examples/audit-trail/real-world/01_customs_clearance.rs @@ -5,6 +5,22 @@ //! //! This example models a customs-clearance process for a single shipment. //! +//! ## Actors +//! +//! - **Admin**: Creates the trail and sets up all roles and capabilities. +//! - **DocsOperator**: Handles document submission (invoices, packing lists). Writes only +//! `documents`-tagged records. +//! - **ExportBroker**: Files export declarations and records clearance decisions at the origin. +//! Writes only `export`-tagged records. +//! - **ImportBroker**: Handles duty assessment and import clearance at the destination. +//! Writes only `import`-tagged records. +//! - **Inspector**: Records the outcome of a customs physical inspection. Writes only +//! `inspection`-tagged records; the role is created mid-process when an inspection is +//! triggered. +//! - **Supervisor**: Updates the mutable trail metadata (processing status). No record-write +//! permissions. +//! - **LockingAdmin**: Freezes the trail once the shipment is fully cleared. +//! //! ## How the trail is used //! //! - `immutable_metadata`: shipment and declaration identity @@ -23,16 +39,25 @@ use examples::get_funded_audit_trail_client; use iota_sdk::types::base_types::{IotaAddress, ObjectID}; use product_common::core_client::CoreClient; use product_common::test_utils::InMemSigner; +use sha2::{Digest, Sha256}; #[tokio::main] async fn main() -> Result<()> { println!("=== Customs Clearance ===\n"); - let client = get_funded_audit_trail_client().await?; + let admin = get_funded_audit_trail_client().await?; + let docs_operator = get_funded_audit_trail_client().await?; + let export_broker = get_funded_audit_trail_client().await?; + let import_broker = get_funded_audit_trail_client().await?; + let supervisor = get_funded_audit_trail_client().await?; + let locking_admin = get_funded_audit_trail_client().await?; + let inspector = get_funded_audit_trail_client().await?; + + // === Create the customs-clearance trail === println!("Creating a customs-clearance trail..."); - let created = client + let created = admin .create_trail() .with_record_tags(["documents", "export", "import", "inspection"]) .with_trail_metadata(ImmutableMetadata::new( @@ -51,75 +76,84 @@ async fn main() -> Result<()> { Some("documents".to_string()), )) .finish() - .build_and_execute(&client) + .build_and_execute(&admin) .await? .output; let trail_id = created.trail_id; - issue_tagged_record_role(&client, trail_id, "DocsOperator", "documents", client.sender_address()).await?; - issue_tagged_record_role(&client, trail_id, "ExportBroker", "export", client.sender_address()).await?; - issue_tagged_record_role(&client, trail_id, "ImportBroker", "import", client.sender_address()).await?; + // === Set up roles and capabilities for each actor === - client + issue_tagged_record_role(&admin, trail_id, "DocsOperator", "documents", docs_operator.sender_address()).await?; + issue_tagged_record_role(&admin, trail_id, "ExportBroker", "export", export_broker.sender_address()).await?; + issue_tagged_record_role(&admin, trail_id, "ImportBroker", "import", import_broker.sender_address()).await?; + + admin .trail(trail_id) .access() .for_role("Supervisor") .create(PermissionSet::metadata_admin_permissions(), None) - .build_and_execute(&client) + .build_and_execute(&admin) .await?; - client + admin .trail(trail_id) .access() .for_role("Supervisor") .issue_capability(CapabilityIssueOptions { - issued_to: Some(client.sender_address()), + issued_to: Some(supervisor.sender_address()), valid_from_ms: None, valid_until_ms: None, }) - .build_and_execute(&client) + .build_and_execute(&admin) .await?; - client + admin .trail(trail_id) .access() .for_role("LockingAdmin") .create(PermissionSet::locking_admin_permissions(), None) - .build_and_execute(&client) + .build_and_execute(&admin) .await?; - client + admin .trail(trail_id) .access() .for_role("LockingAdmin") .issue_capability(CapabilityIssueOptions { - issued_to: Some(client.sender_address()), + issued_to: Some(locking_admin.sender_address()), valid_from_ms: None, valid_until_ms: None, }) - .build_and_execute(&client) + .build_and_execute(&admin) .await?; - let docs_uploaded = client + // === Document submission === + + // Documents are stored off-chain in an access-controlled environment (e.g. a TWIN node). + // Only the SHA-256 fingerprint is committed on-chain for tamper-evidence. + let invoice_hash = Sha256::digest(b"invoice-SHP-2026-CLEAR-001-v1.pdf"); + let docs_uploaded = docs_operator .trail(trail_id) .records() .add( - Data::text("Commercial invoice and packing list uploaded"), + Data::bytes(invoice_hash.to_vec()), Some("event:documents_uploaded".to_string()), Some("documents".to_string()), ) - .build_and_execute(&client) + .build_and_execute(&docs_operator) .await? .output; println!("Docs operator added record #{}.\n", docs_uploaded.sequence_number); - client + supervisor .trail(trail_id) .update_metadata(Some("Status: Awaiting Export Clearance".to_string())) - .build_and_execute(&client) + .build_and_execute(&supervisor) .await?; - let export_filed = client + // === Export clearance === + + let export_filed = export_broker .trail(trail_id) .records() .add( @@ -127,11 +161,11 @@ async fn main() -> Result<()> { Some("event:export_declaration_filed".to_string()), Some("export".to_string()), ) - .build_and_execute(&client) + .build_and_execute(&export_broker) .await? .output; - let export_cleared = client + let export_cleared = export_broker .trail(trail_id) .records() .add( @@ -139,7 +173,7 @@ async fn main() -> Result<()> { Some("event:export_cleared".to_string()), Some("export".to_string()), ) - .build_and_execute(&client) + .build_and_execute(&export_broker) .await? .output; @@ -148,13 +182,17 @@ async fn main() -> Result<()> { export_filed.sequence_number, export_cleared.sequence_number ); - client + supervisor .trail(trail_id) .update_metadata(Some("Status: Awaiting Import Clearance".to_string())) - .build_and_execute(&client) + .build_and_execute(&supervisor) .await?; - let denied_inspection = client + // === Inspection gate === + + // The import broker does not hold an inspection-scoped capability at this point. + // The write attempt must fail to prove that tag-based access control is enforced. + let denied_inspection = import_broker .trail(trail_id) .records() .add( @@ -162,7 +200,7 @@ async fn main() -> Result<()> { Some("event:invalid_inspection_write".to_string()), Some("inspection".to_string()), ) - .build_and_execute(&client) + .build_and_execute(&import_broker) .await; ensure!( @@ -171,9 +209,10 @@ async fn main() -> Result<()> { ); println!("Inspection write was correctly denied before the inspector role existed.\n"); - issue_tagged_record_role(&client, trail_id, "Inspector", "inspection", client.sender_address()).await?; + // A customs inspection is triggered; the inspector role is created and issued mid-process. + issue_tagged_record_role(&admin, trail_id, "Inspector", "inspection", inspector.sender_address()).await?; - let inspection_done = client + let inspection_done = inspector .trail(trail_id) .records() .add( @@ -181,13 +220,15 @@ async fn main() -> Result<()> { Some("event:inspection_completed".to_string()), Some("inspection".to_string()), ) - .build_and_execute(&client) + .build_and_execute(&inspector) .await? .output; println!("Inspector added record #{}.\n", inspection_done.sequence_number); - let duty_assessed = client + // === Import clearance === + + let duty_assessed = import_broker .trail(trail_id) .records() .add( @@ -195,11 +236,11 @@ async fn main() -> Result<()> { Some("event:duty_assessed".to_string()), Some("import".to_string()), ) - .build_and_execute(&client) + .build_and_execute(&import_broker) .await? .output; - let import_cleared = client + let import_cleared = import_broker .trail(trail_id) .records() .add( @@ -207,7 +248,7 @@ async fn main() -> Result<()> { Some("event:import_cleared".to_string()), Some("import".to_string()), ) - .build_and_execute(&client) + .build_and_execute(&import_broker) .await? .output; @@ -216,26 +257,28 @@ async fn main() -> Result<()> { duty_assessed.sequence_number, import_cleared.sequence_number ); - client + supervisor .trail(trail_id) .update_metadata(Some("Status: Cleared".to_string())) - .build_and_execute(&client) + .build_and_execute(&supervisor) .await?; - client + // === Final lock and verification === + + locking_admin .trail(trail_id) .locking() .update_write_lock(TimeLock::Infinite) - .build_and_execute(&client) + .build_and_execute(&locking_admin) .await?; - let after_lock = client.trail(trail_id).get().await?; + let after_lock = admin.trail(trail_id).get().await?; println!( "Write lock after clearance: {:?}\n", after_lock.locking_config.write_lock ); - let late_note = client + let late_note = docs_operator .trail(trail_id) .records() .add( @@ -243,7 +286,7 @@ async fn main() -> Result<()> { Some("event:late_note".to_string()), Some("documents".to_string()), ) - .build_and_execute(&client) + .build_and_execute(&docs_operator) .await; ensure!( @@ -251,7 +294,7 @@ async fn main() -> Result<()> { "cleared customs trail should reject late writes after the final lock" ); - let trail = client.trail(trail_id); + let trail = admin.trail(trail_id); let first_page = trail.records().list_page(None, 20).await?; println!("Recorded customs events:"); diff --git a/examples/audit-trail/real-world/02_clinical_trial.rs b/examples/audit-trail/real-world/02_clinical_trial.rs index 8966f36b..54d55bae 100644 --- a/examples/audit-trail/real-world/02_clinical_trial.rs +++ b/examples/audit-trail/real-world/02_clinical_trial.rs @@ -6,6 +6,20 @@ //! This example models a Phase III clinical trial where an immutable audit trail //! guarantees data integrity, role-scoped access, and time-constrained oversight. //! +//! ## Actors +//! +//! - **Admin**: Creates the trail and sets up all roles and capabilities. +//! - **Enroller**: Writes enrollment events. Restricted to the `enrollment` tag. +//! - **SafetyOfficer**: Records adverse events and safety observations. Restricted to `safety`. +//! - **EfficacyReviewer**: Records treatment outcomes. Restricted to `efficacy`. +//! - **PkAnalyst**: Records pharmacokinetic results. Restricted to the `pk` tag that is added +//! mid-study when a PK sub-study is initiated. +//! - **Monitor**: Updates the mutable study-phase metadata. Access is time-windowed to the +//! active study period (90 days from now). +//! - **DataSafetyBoard**: Controls write and delete locks. Freezes the dataset after review. +//! - **Regulator**: Read-only verifier. In production this would use `AuditTrailClientReadOnly` +//! (no signing key); here a funded client is used to keep the example self-contained. +//! //! ## How the trail is used //! //! - `immutable_metadata`: protocol identity and study description @@ -13,8 +27,8 @@ //! - record tags: `enrollment`, `safety`, `efficacy`, `pk` (added mid-study) //! - roles and capabilities: each role writes only its designated tag //! - time-constrained capabilities: Monitor access is windowed to the study period -//! - locking: a deletion window protects recent records; a time-lock freezes the dataset after the Data Safety Board -//! completes its review +//! - locking: a deletion window protects recent records; a time-lock freezes the dataset after +//! the Data Safety Board completes its review //! - read-only verification: a regulator inspects the trail without write access use anyhow::{Result, ensure}; @@ -32,14 +46,21 @@ use product_common::test_utils::InMemSigner; async fn main() -> Result<()> { println!("=== Clinical Trial Data Integrity ===\n"); - let client = get_funded_audit_trail_client().await?; + let admin = get_funded_audit_trail_client().await?; + let enroller = get_funded_audit_trail_client().await?; + let safety_officer = get_funded_audit_trail_client().await?; + let efficacy_reviewer = get_funded_audit_trail_client().await?; + let pk_analyst = get_funded_audit_trail_client().await?; + let monitor = get_funded_audit_trail_client().await?; + let data_safety_board = get_funded_audit_trail_client().await?; + let regulator = get_funded_audit_trail_client().await?; // ----------------------------------------------------------------------- // 1. Create the trial trail // ----------------------------------------------------------------------- println!("Creating the clinical-trial audit trail..."); - let created = client + let created = admin .create_trail() .with_record_tags(["enrollment", "safety", "efficacy"]) .with_trail_metadata(ImmutableMetadata::new( @@ -58,7 +79,7 @@ async fn main() -> Result<()> { Some("enrollment".to_string()), )) .finish() - .build_and_execute(&client) + .build_and_execute(&admin) .await? .output; @@ -70,64 +91,64 @@ async fn main() -> Result<()> { // ----------------------------------------------------------------------- println!("Defining study roles..."); - issue_tagged_record_role(&client, trail_id, "Enroller", "enrollment", client.sender_address()).await?; - issue_tagged_record_role(&client, trail_id, "SafetyOfficer", "safety", client.sender_address()).await?; + issue_tagged_record_role(&admin, trail_id, "Enroller", "enrollment", enroller.sender_address()).await?; + issue_tagged_record_role(&admin, trail_id, "SafetyOfficer", "safety", safety_officer.sender_address()).await?; issue_tagged_record_role( - &client, + &admin, trail_id, "EfficacyReviewer", "efficacy", - client.sender_address(), + efficacy_reviewer.sender_address(), ) .await?; - // Monitor can update metadata (study phase) but only during the study window - client + // Monitor can update metadata (study phase) but only during the study window. + admin .trail(trail_id) .access() .for_role("Monitor") .create(PermissionSet::metadata_admin_permissions(), None) - .build_and_execute(&client) + .build_and_execute(&admin) .await?; let now_ms = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH)? .as_millis() as u64; - // Monitor access is valid for 90 days from now + // Monitor access is valid for 90 days from now. let study_end_ms = now_ms + 90 * 24 * 60 * 60 * 1000; - client + admin .trail(trail_id) .access() .for_role("Monitor") .issue_capability(CapabilityIssueOptions { - issued_to: Some(client.sender_address()), + issued_to: Some(monitor.sender_address()), valid_from_ms: Some(now_ms), valid_until_ms: Some(study_end_ms), }) - .build_and_execute(&client) + .build_and_execute(&admin) .await?; println!("Monitor capability issued (valid for 90 days from now, ends at timestamp {study_end_ms})\n"); - // Data Safety Board can manage locking - client + // Data Safety Board can manage locking. + admin .trail(trail_id) .access() .for_role("DataSafetyBoard") .create(PermissionSet::locking_admin_permissions(), None) - .build_and_execute(&client) + .build_and_execute(&admin) .await?; - client + admin .trail(trail_id) .access() .for_role("DataSafetyBoard") .issue_capability(CapabilityIssueOptions { - issued_to: Some(client.sender_address()), + issued_to: Some(data_safety_board.sender_address()), valid_from_ms: None, valid_until_ms: None, }) - .build_and_execute(&client) + .build_and_execute(&admin) .await?; // ----------------------------------------------------------------------- @@ -135,7 +156,7 @@ async fn main() -> Result<()> { // ----------------------------------------------------------------------- println!("--- Enrollment Phase ---"); - let enrolled = client + let enrolled = enroller .trail(trail_id) .records() .add( @@ -143,7 +164,7 @@ async fn main() -> Result<()> { Some("event:patient_enrolled".to_string()), Some("enrollment".to_string()), ) - .build_and_execute(&client) + .build_and_execute(&enroller) .await? .output; println!("Enroller added record #{}.\n", enrolled.sequence_number); @@ -153,7 +174,7 @@ async fn main() -> Result<()> { // ----------------------------------------------------------------------- println!("--- Study Data Collection ---"); - let safety_event = client + let safety_event = safety_officer .trail(trail_id) .records() .add( @@ -161,11 +182,11 @@ async fn main() -> Result<()> { Some("event:adverse_event".to_string()), Some("safety".to_string()), ) - .build_and_execute(&client) + .build_and_execute(&safety_officer) .await? .output; - let efficacy_record = client + let efficacy_record = efficacy_reviewer .trail(trail_id) .records() .add( @@ -173,7 +194,7 @@ async fn main() -> Result<()> { Some("event:efficacy_observed".to_string()), Some("efficacy".to_string()), ) - .build_and_execute(&client) + .build_and_execute(&efficacy_reviewer) .await? .output; @@ -187,18 +208,18 @@ async fn main() -> Result<()> { // ----------------------------------------------------------------------- println!("--- Mid-Study Amendment ---"); - client + // Admin adds the new tag and creates a role for the PK analyst. + admin .trail(trail_id) .tags() .add("pk") - .build_and_execute(&client) + .build_and_execute(&admin) .await?; println!("Added tag 'pk' (pharmacokinetics) to the trail."); - // Now create a role for the new tag - issue_tagged_record_role(&client, trail_id, "PkAnalyst", "pk", client.sender_address()).await?; + issue_tagged_record_role(&admin, trail_id, "PkAnalyst", "pk", pk_analyst.sender_address()).await?; - let pk_record = client + let pk_record = pk_analyst .trail(trail_id) .records() .add( @@ -206,7 +227,7 @@ async fn main() -> Result<()> { Some("event:pk_result".to_string()), Some("pk".to_string()), ) - .build_and_execute(&client) + .build_and_execute(&pk_analyst) .await? .output; println!("PkAnalyst added record #{}.\n", pk_record.sequence_number); @@ -216,11 +237,11 @@ async fn main() -> Result<()> { // ----------------------------------------------------------------------- println!("--- Deletion Window Enforcement ---"); - let delete_attempt = client + let delete_attempt = pk_analyst .trail(trail_id) .records() .delete(pk_record.sequence_number) - .build_and_execute(&client) + .build_and_execute(&pk_analyst) .await; ensure!( @@ -237,14 +258,14 @@ async fn main() -> Result<()> { // ----------------------------------------------------------------------- println!("--- Metadata Update ---"); - client + monitor .trail(trail_id) .update_metadata(Some("Phase: Data Review".to_string())) - .build_and_execute(&client) + .build_and_execute(&monitor) .await?; - let trail = client.trail(trail_id).get().await?; - println!("Study phase updated to: {:?}\n", trail.updatable_metadata); + let current_state = admin.trail(trail_id).get().await?; + println!("Study phase updated to: {:?}\n", current_state.updatable_metadata); // ----------------------------------------------------------------------- // 8. Data Safety Board locks the study dataset @@ -252,17 +273,17 @@ async fn main() -> Result<()> { println!("--- Data Safety Board Lock ---"); // Lock writes until a specific future timestamp (e.g. 1 year from now), - // then the dataset becomes permanently locked. + // after which the dataset becomes permanently locked. let lock_until_ms = now_ms + 365 * 24 * 60 * 60 * 1000; // 1 year from now - client + data_safety_board .trail(trail_id) .locking() .update_write_lock(TimeLock::UnlockAtMs(lock_until_ms)) - .build_and_execute(&client) + .build_and_execute(&data_safety_board) .await?; - let locked_trail = client.trail(trail_id).get().await?; + let locked_trail = admin.trail(trail_id).get().await?; println!( "Write lock set to UnlockAtMs({}) — writes blocked until that timestamp.\n", lock_until_ms @@ -270,14 +291,14 @@ async fn main() -> Result<()> { println!("Current locking config: {:?}\n", locked_trail.locking_config); // Also lock the trail from deletion permanently. - client + data_safety_board .trail(trail_id) .locking() .update_delete_trail_lock(TimeLock::Infinite) - .build_and_execute(&client) + .build_and_execute(&data_safety_board) .await?; - let final_locking = client.trail(trail_id).get().await?; + let final_locking = admin.trail(trail_id).get().await?; println!( "Delete-trail lock set to {:?} — trail cannot be deleted.\n", final_locking.locking_config.delete_trail_lock @@ -288,9 +309,8 @@ async fn main() -> Result<()> { // ----------------------------------------------------------------------- println!("--- Regulator Verification ---"); - // In production the regulator would use AuditTrailClientReadOnly (no signer), - // but for this example we reuse the funded client to demonstrate read-only methods. - let regulator_handle = client.trail(trail_id); + // In production the regulator would use AuditTrailClientReadOnly (no signer). + let regulator_handle = regulator.trail(trail_id); let on_chain = regulator_handle.get().await?; println!("Protocol: {:?}", on_chain.immutable_metadata); From 8b3a92b7d61db275372ebed373e0c2517ee01a72 Mon Sep 17 00:00:00 2001 From: Yasir Date: Fri, 10 Apr 2026 15:57:22 +0300 Subject: [PATCH 153/189] feat: apply multi-actor pattern to all TypeScript WASM audit trail examples Each actor now uses its own funded client with a distinct keypair, so capability objects are bound to per-actor addresses and the Move-level access-control is exercised as intended. All examples have structured JSDoc ## Actors sections. The issueTaggedRecordRole helper accepts an explicit issuedTo address parameter. --- .../examples/src/01_create_audit_trail.ts | 39 ++-- .../examples/src/02_add_and_read_records.ts | 44 +++- .../examples/src/03_update_metadata.ts | 60 ++++-- .../examples/src/04_configure_locking.ts | 92 +++++---- .../examples/src/05_manage_access.ts | 56 +++-- .../examples/src/06_delete_records.ts | 73 ++++--- .../src/07_access_read_only_methods.ts | 58 ++++-- .../examples/src/08_delete_audit_trail.ts | 61 +++--- .../src/advanced/09_tagged_records.ts | 23 ++- .../src/advanced/10_capability_constraints.ts | 30 ++- .../src/advanced/11_manage_record_tags.ts | 75 ++++--- .../src/real-world/01_customs_clearance.ts | 187 ++++++++++------- .../src/real-world/02_clinical_trial.ts | 192 ++++++++++-------- 13 files changed, 607 insertions(+), 383 deletions(-) diff --git a/bindings/wasm/audit_trail_wasm/examples/src/01_create_audit_trail.ts b/bindings/wasm/audit_trail_wasm/examples/src/01_create_audit_trail.ts index e780986a..2ce12cf4 100644 --- a/bindings/wasm/audit_trail_wasm/examples/src/01_create_audit_trail.ts +++ b/bindings/wasm/audit_trail_wasm/examples/src/01_create_audit_trail.ts @@ -1,21 +1,36 @@ // Copyright 2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -import { CapabilityIssueOptions, PermissionSet } from "@iota/audit-trail/node"; -import { strict as assert } from "assert"; -import { createTrailWithSeedRecord, getFundedClient, TEST_GAS_BUDGET } from "./util"; - /** + * ## Actors + * + * - **Admin**: Creates the trail and holds the built-in Admin capability that is + * automatically minted on creation. + * - **RecordAdmin**: Receives a RecordAdmin capability bound to their address. Writes + * records in subsequent examples. + * * Demonstrates how to: * 1. Create an audit trail with immutable metadata, updatable metadata, and a seed record. * 2. Inspect the built-in Admin role. * 3. Define a RecordAdmin role and issue a capability for it. */ + +import { CapabilityIssueOptions, PermissionSet } from "@iota/audit-trail/node"; +import { strict as assert } from "assert"; +import { createTrailWithSeedRecord, getFundedClient, TEST_GAS_BUDGET } from "./util"; + export async function createAuditTrail(): Promise { console.log("Creating an audit trail"); - const client = await getFundedClient(); - const { output: trail, response } = await createTrailWithSeedRecord(client); + // `admin` creates the trail and holds the Admin capability. + // `recordAdmin` receives the RecordAdmin capability. + const admin = await getFundedClient(); + const recordAdmin = await getFundedClient(); + + console.log("Admin address: ", admin.senderAddress()); + console.log("RecordAdmin address: ", recordAdmin.senderAddress()); + + const { output: trail, response } = await createTrailWithSeedRecord(admin); console.log(`Created trail ${trail.id} with transaction ${response.digest}`); console.log("Immutable metadata:", trail.immutableMetadata); @@ -26,18 +41,18 @@ export async function createAuditTrail(): Promise { assert.ok(trail.immutableMetadata); assert.equal(trail.immutableMetadata?.name, "Example Audit Trail"); - // Define a RecordAdmin role and issue a capability - const role = client.trail(trail.id).access().forRole("RecordAdmin"); + // Define a RecordAdmin role and issue the capability to recordAdmin's address. + const role = admin.trail(trail.id).access().forRole("RecordAdmin"); await role .create(PermissionSet.recordAdminPermissions()) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); + .buildAndExecute(admin); await role - .issueCapability(new CapabilityIssueOptions(client.senderAddress())) + .issueCapability(new CapabilityIssueOptions(recordAdmin.senderAddress())) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); + .buildAndExecute(admin); - const onChain = await client.trail(trail.id).get(); + const onChain = await admin.trail(trail.id).get(); const roleNames = onChain.roles.roles.map((r) => r.name); console.log("Roles:", roleNames); assert.ok(roleNames.includes("RecordAdmin")); diff --git a/bindings/wasm/audit_trail_wasm/examples/src/02_add_and_read_records.ts b/bindings/wasm/audit_trail_wasm/examples/src/02_add_and_read_records.ts index 0858de8c..161f788e 100644 --- a/bindings/wasm/audit_trail_wasm/examples/src/02_add_and_read_records.ts +++ b/bindings/wasm/audit_trail_wasm/examples/src/02_add_and_read_records.ts @@ -1,33 +1,57 @@ // Copyright 2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -import { Data } from "@iota/audit-trail/node"; -import { strict as assert } from "assert"; -import { createTrailWithSeedRecord, getFundedClient, grantSelfRecordPermissions, TEST_GAS_BUDGET } from "./util"; - /** + * ## Actors + * + * - **Admin**: Creates the trail, defines the RecordAdmin role, and issues a capability. + * - **RecordAdmin**: Holds the capability and writes records. Reads are also done through + * this client to demonstrate that any address can read, but only the cap holder can write. + * * Demonstrates how to: * 1. Add follow-up records to a trail. * 2. Read them back individually by sequence number. * 3. Paginate through records. */ + +import { CapabilityIssueOptions, Data, PermissionSet } from "@iota/audit-trail/node"; +import { strict as assert } from "assert"; +import { createTrailWithSeedRecord, getFundedClient, TEST_GAS_BUDGET } from "./util"; + export async function addAndReadRecords(): Promise { console.log("Adding records and reading them back with pagination"); - const client = await getFundedClient(); - const { output: trail } = await createTrailWithSeedRecord(client); - await grantSelfRecordPermissions(client, trail.id); - const records = client.trail(trail.id).records(); + // `admin` creates the trail and sets up the role. + // `recordAdmin` holds the capability and writes/reads records. + const admin = await getFundedClient(); + const recordAdmin = await getFundedClient(); + + const { output: trail } = await createTrailWithSeedRecord(admin); + const trailId = trail.id; + + // Create a RecordAdmin role and issue the capability to recordAdmin's address. + const role = admin.trail(trailId).access().forRole("RecordAdmin"); + await role + .create(PermissionSet.recordAdminPermissions()) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(admin); + await role + .issueCapability(new CapabilityIssueOptions(recordAdmin.senderAddress())) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(admin); + + // The client automatically finds the capability in recordAdmin's wallet. + const records = recordAdmin.trail(trailId).records(); // Add records const addedSecond = await records .add(Data.fromString("record 2"), "second") .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); + .buildAndExecute(recordAdmin); const addedThird = await records .add(Data.fromString("record 3"), "third") .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); + .buildAndExecute(recordAdmin); console.log("Added records:", addedSecond.output, addedThird.output); diff --git a/bindings/wasm/audit_trail_wasm/examples/src/03_update_metadata.ts b/bindings/wasm/audit_trail_wasm/examples/src/03_update_metadata.ts index 2935ba79..7b3e69b0 100644 --- a/bindings/wasm/audit_trail_wasm/examples/src/03_update_metadata.ts +++ b/bindings/wasm/audit_trail_wasm/examples/src/03_update_metadata.ts @@ -1,53 +1,67 @@ // Copyright 2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -import { CapabilityIssueOptions, PermissionSet } from "@iota/audit-trail/node"; -import { strict as assert } from "assert"; -import { createTrailWithSeedRecord, getFundedClient, TEST_GAS_BUDGET } from "./util"; - /** + * ## Actors + * + * - **Admin**: Creates the trail and sets up the MetadataAdmin role. + * - **MetadataAdmin**: Holds the MetadataAdmin capability and updates the trail's mutable + * status field. Has no record-write permissions. + * * Demonstrates how to: * 1. Create a trail with immutable and updatable metadata. * 2. Delegate metadata updates through a dedicated MetadataAdmin role. * 3. Change and clear the trail's updatable metadata. * 4. Verify that immutable metadata never changes. */ + +import { CapabilityIssueOptions, PermissionSet } from "@iota/audit-trail/node"; +import { strict as assert } from "assert"; +import { getFundedClient, TEST_GAS_BUDGET } from "./util"; + export async function updateMetadata(): Promise { console.log("=== Audit Trail: Update Metadata ===\n"); - const client = await getFundedClient(); - const { output: trail } = await client + // `admin` creates the trail and sets up the role. + // `metadataAdmin` holds the MetadataAdmin capability and updates the status. + const admin = await getFundedClient(); + const metadataAdmin = await getFundedClient(); + + const { output: trail } = await admin .createTrail() .withTrailMetadata("Shipment Processing", "Tracks the lifecycle of a warehouse shipment") .withUpdatableMetadata("Status: Draft") .withInitialRecordString("Shipment created", "event:created") .finish() .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); + .buildAndExecute(admin); const trailId = trail.id; - const trailHandle = client.trail(trailId); - // Delegate metadata updates to a MetadataAdmin role - const role = trailHandle.access().forRole("MetadataAdmin"); - await role.create(PermissionSet.metadataAdminPermissions()).withGasBudget(TEST_GAS_BUDGET).buildAndExecute(client); + // Delegate metadata updates to a MetadataAdmin role. + const role = admin.trail(trailId).access().forRole("MetadataAdmin"); await role - .issueCapability(new CapabilityIssueOptions(client.senderAddress())) + .create(PermissionSet.metadataAdminPermissions()) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); + .buildAndExecute(admin); + await role + .issueCapability(new CapabilityIssueOptions(metadataAdmin.senderAddress())) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(admin); - const before = await trailHandle.get(); + const before = await admin.trail(trailId).get(); console.log("Before update:"); console.log(" immutable =", before.immutableMetadata); console.log(" updatable =", before.updatableMetadata, "\n"); - // Update the mutable metadata - await trailHandle + // MetadataAdmin updates the mutable metadata. + await metadataAdmin + .trail(trailId) .updateMetadata("Status: In Review") .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); + .buildAndExecute(metadataAdmin); - const afterUpdate = await trailHandle.get(); + const afterUpdate = await admin.trail(trailId).get(); console.log("After update:"); console.log(" immutable =", afterUpdate.immutableMetadata); console.log(" updatable =", afterUpdate.updatableMetadata, "\n"); @@ -55,10 +69,14 @@ export async function updateMetadata(): Promise { assert.equal(afterUpdate.immutableMetadata?.name, "Shipment Processing"); assert.equal(afterUpdate.updatableMetadata, "Status: In Review"); - // Clear the mutable metadata - await trailHandle.updateMetadata(undefined).withGasBudget(TEST_GAS_BUDGET).buildAndExecute(client); + // MetadataAdmin clears the mutable metadata. + await metadataAdmin + .trail(trailId) + .updateMetadata(undefined) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(metadataAdmin); - const afterClear = await trailHandle.get(); + const afterClear = await admin.trail(trailId).get(); console.log("After clear:"); console.log(" immutable =", afterClear.immutableMetadata); console.log(" updatable =", afterClear.updatableMetadata); diff --git a/bindings/wasm/audit_trail_wasm/examples/src/04_configure_locking.ts b/bindings/wasm/audit_trail_wasm/examples/src/04_configure_locking.ts index bbeeb418..7b282f36 100644 --- a/bindings/wasm/audit_trail_wasm/examples/src/04_configure_locking.ts +++ b/bindings/wasm/audit_trail_wasm/examples/src/04_configure_locking.ts @@ -1,6 +1,21 @@ // Copyright 2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +/** + * ## Actors + * + * - **Admin**: Creates the trail and sets up the LockingAdmin and RecordAdmin roles. + * - **LockingAdmin**: Controls write and delete locks. Holds the LockingAdmin capability. + * - **RecordAdmin**: Writes records. Used to demonstrate that the write lock is enforced + * per-sender, not just checked by the admin. + * + * Demonstrates how to: + * 1. Delegate locking updates through a LockingAdmin role. + * 2. Freeze record creation with a write lock. + * 3. Restore writes and add a new record. + * 4. Update the delete-record window and delete-trail lock. + */ + import { CapabilityIssueOptions, Data, @@ -12,89 +27,92 @@ import { import { strict as assert } from "assert"; import { createTrailWithSeedRecord, getFundedClient, TEST_GAS_BUDGET } from "./util"; -/** - * Demonstrates how to: - * 1. Delegate locking updates through a LockingAdmin role. - * 2. Freeze record creation with a write lock. - * 3. Restore writes and add a new record. - * 4. Update the delete-record window and delete-trail lock. - */ export async function configureLocking(): Promise { console.log("=== Audit Trail: Configure Locking ===\n"); - const client = await getFundedClient(); - const { output: trail } = await createTrailWithSeedRecord(client); + // `admin` creates the trail and sets up roles. + // `lockingAdmin` controls locks; `recordAdmin` writes records. + const admin = await getFundedClient(); + const lockingAdmin = await getFundedClient(); + const recordAdmin = await getFundedClient(); + + const { output: trail } = await createTrailWithSeedRecord(admin); const trailId = trail.id; - const trailHandle = client.trail(trailId); - // Create LockingAdmin and RecordAdmin roles - const lockingRole = trailHandle.access().forRole("LockingAdmin"); + // Create LockingAdmin and RecordAdmin roles. + const lockingRole = admin.trail(trailId).access().forRole("LockingAdmin"); await lockingRole .create(PermissionSet.lockingAdminPermissions()) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); + .buildAndExecute(admin); await lockingRole - .issueCapability(new CapabilityIssueOptions(client.senderAddress())) + .issueCapability(new CapabilityIssueOptions(lockingAdmin.senderAddress())) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); + .buildAndExecute(admin); - const recordRole = trailHandle.access().forRole("RecordAdmin"); + const recordRole = admin.trail(trailId).access().forRole("RecordAdmin"); await recordRole .create(PermissionSet.recordAdminPermissions()) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); + .buildAndExecute(admin); await recordRole - .issueCapability(new CapabilityIssueOptions(client.senderAddress())) + .issueCapability(new CapabilityIssueOptions(recordAdmin.senderAddress())) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); + .buildAndExecute(admin); - // Freeze writes - await trailHandle + // LockingAdmin freezes writes. + await lockingAdmin + .trail(trailId) .locking() .updateWriteLock(TimeLock.withInfinite()) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); + .buildAndExecute(lockingAdmin); - const locked = await trailHandle.get(); + const locked = await admin.trail(trailId).get(); console.log("Write lock after update:", locked.lockingConfig.writeLock, "\n"); assert.equal(locked.lockingConfig.writeLock.type, TimeLock.withInfinite().type); - // Attempt to add a record while locked — should fail - const blockedAdd = await trailHandle + // RecordAdmin attempts to add a record while locked — should fail. + const blockedAdd = await recordAdmin + .trail(trailId) .records() .add(Data.fromString("This write should fail"), "blocked") .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client) + .buildAndExecute(recordAdmin) .catch(() => null); assert.equal(blockedAdd, null, "write lock should block adding records"); - // Lift the write lock - await trailHandle + // LockingAdmin lifts the write lock. + await lockingAdmin + .trail(trailId) .locking() .updateWriteLock(TimeLock.withNone()) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); + .buildAndExecute(lockingAdmin); - const added = await trailHandle + const added = await recordAdmin + .trail(trailId) .records() .add(Data.fromString("Write lock lifted"), "event:resumed") .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); + .buildAndExecute(recordAdmin); console.log("Added record", added.output.sequenceNumber, "after clearing the write lock.\n"); - // Configure deletion window and trail lock - await trailHandle + // LockingAdmin configures deletion window and trail lock. + await lockingAdmin + .trail(trailId) .locking() .updateDeleteRecordWindow(LockingWindow.withCountBased(BigInt(2))) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); - await trailHandle + .buildAndExecute(lockingAdmin); + await lockingAdmin + .trail(trailId) .locking() .updateDeleteTrailLock(TimeLock.withInfinite()) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); + .buildAndExecute(lockingAdmin); - const finalState = await trailHandle.get(); + const finalState = await admin.trail(trailId).get(); console.log("Final locking config:"); console.log(" delete_record_window =", finalState.lockingConfig.deleteRecordWindow); console.log(" delete_trail_lock =", finalState.lockingConfig.deleteTrailLock); diff --git a/bindings/wasm/audit_trail_wasm/examples/src/05_manage_access.ts b/bindings/wasm/audit_trail_wasm/examples/src/05_manage_access.ts index 9ca58010..0107db58 100644 --- a/bindings/wasm/audit_trail_wasm/examples/src/05_manage_access.ts +++ b/bindings/wasm/audit_trail_wasm/examples/src/05_manage_access.ts @@ -1,31 +1,43 @@ // Copyright 2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -import { CapabilityIssueOptions, Permission, PermissionSet } from "@iota/audit-trail/node"; -import { strict as assert } from "assert"; -import { createTrailWithSeedRecord, getFundedClient, TEST_GAS_BUDGET } from "./util"; - /** + * ## Actors + * + * - **Admin**: Creates and updates roles, issues capabilities, revokes and destroys them, + * and finally deletes the role once it is no longer needed. + * - **OperationsUser**: The subject of all capability issuance. Capabilities are bound to + * this address to demonstrate that revocation immediately blocks their access. + * * Demonstrates how to: * 1. Create and update a custom role. * 2. Issue a constrained capability for that role. * 3. Revoke one capability and destroy another. * 4. Remove the role after its capabilities are no longer needed. */ + +import { CapabilityIssueOptions, Permission, PermissionSet } from "@iota/audit-trail/node"; +import { strict as assert } from "assert"; +import { createTrailWithSeedRecord, getFundedClient, TEST_GAS_BUDGET } from "./util"; + export async function manageAccess(): Promise { console.log("=== Audit Trail: Manage Access ===\n"); - const client = await getFundedClient(); - const { output: trail } = await createTrailWithSeedRecord(client); + // `admin` manages roles and the full capability lifecycle. + // `operationsUser` is the target of all capability issuance. + const admin = await getFundedClient(); + const operationsUser = await getFundedClient(); + + const { output: trail } = await createTrailWithSeedRecord(admin); const trailId = trail.id; - const trailHandle = client.trail(trailId); + const trailHandle = admin.trail(trailId); const role = trailHandle.access().forRole("Operations"); // 1. Create the role const createdRole = await role .create(PermissionSet.recordAdminPermissions()) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); + .buildAndExecute(admin); console.log("Created role:", createdRole.output.role, "\n"); // 2. Update the role permissions @@ -38,20 +50,20 @@ export async function manageAccess(): Promise { const updatedRole = await role .updatePermissions(updatedPermissions) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); + .buildAndExecute(admin); console.log("Updated role permissions:", updatedRole.output.permissions.permissions.map((p) => p.toString())); - // 3. Issue a constrained capability (address-bound, time-limited) + // 3. Issue a constrained capability bound to operationsUser's address. const constrainedCap = await role - .issueCapability(new CapabilityIssueOptions(client.senderAddress(), undefined, BigInt(4_102_444_800_000))) + .issueCapability(new CapabilityIssueOptions(operationsUser.senderAddress(), undefined, BigInt(4_102_444_800_000))) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); + .buildAndExecute(admin); console.log("\nIssued constrained capability:"); console.log(" id =", constrainedCap.output.capabilityId); console.log(" issued_to =", constrainedCap.output.issuedTo); console.log(" valid_until =", constrainedCap.output.validUntil, "\n"); - // Verify the on-chain role matches the updated permissions + // Verify the on-chain role matches the updated permissions. const onChain = await trailHandle.get(); const opsRole = onChain.roles.roles.find((r) => r.name === "Operations"); assert.ok(opsRole, "Operations role must exist"); @@ -60,24 +72,24 @@ export async function manageAccess(): Promise { assert(opsPermSet.has(perm.toString()), `role should contain ${perm}`); } - // 4. Revoke the constrained capability + // 4. Revoke the constrained capability. await trailHandle .access() .revokeCapability(constrainedCap.output.capabilityId, constrainedCap.output.validUntil) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); + .buildAndExecute(admin); console.log("Revoked capability", constrainedCap.output.capabilityId, "\n"); - // 5. Issue a disposable capability and destroy it + // 5. Issue a disposable capability and destroy it. const disposableCap = await role - .issueCapability(new CapabilityIssueOptions(client.senderAddress())) + .issueCapability(new CapabilityIssueOptions(operationsUser.senderAddress())) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); + .buildAndExecute(admin); await trailHandle .access() .destroyCapability(disposableCap.output.capabilityId) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); + .buildAndExecute(admin); console.log("Destroyed capability", disposableCap.output.capabilityId, "\n"); // 6. Clean up the revoked-capability registry entry so the role can be removed. @@ -85,11 +97,11 @@ export async function manageAccess(): Promise { .access() .cleanupRevokedCapabilities() .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); + .buildAndExecute(admin); console.log("Cleaned up revoked capability registry entries.\n"); - // 7. Delete the role - await role.delete().withGasBudget(TEST_GAS_BUDGET).buildAndExecute(client); + // 7. Delete the role. + await role.delete().withGasBudget(TEST_GAS_BUDGET).buildAndExecute(admin); const afterDelete = await trailHandle.get(); const opsRoleAfterDelete = afterDelete.roles.roles.find((r) => r.name === "Operations"); assert.equal(opsRoleAfterDelete, undefined, "role should be removed from the trail"); diff --git a/bindings/wasm/audit_trail_wasm/examples/src/06_delete_records.ts b/bindings/wasm/audit_trail_wasm/examples/src/06_delete_records.ts index 550f3fd6..556e6631 100644 --- a/bindings/wasm/audit_trail_wasm/examples/src/06_delete_records.ts +++ b/bindings/wasm/audit_trail_wasm/examples/src/06_delete_records.ts @@ -1,6 +1,19 @@ // Copyright 2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +/** + * ## Actors + * + * - **Admin**: Creates the trail and sets up the RecordMaintenance role. + * - **RecordMaintainer**: Holds the RecordMaintenance capability. Adds records and then + * deletes them individually and in batch. + * + * Demonstrates how to: + * 1. Create records via a delegated RecordMaintenance role. + * 2. Delete a single record by sequence number. + * 3. Batch-delete remaining records. + */ + import { CapabilityIssueOptions, Data, @@ -13,17 +26,15 @@ import { import { strict as assert } from "assert"; import { getFundedClient, TEST_GAS_BUDGET } from "./util"; -/** - * Demonstrates how to: - * 1. Create records via a delegated RecordMaintenance role. - * 2. Delete a single record by sequence number. - * 3. Batch-delete remaining records. - */ export async function deleteRecords(): Promise { console.log("=== Audit Trail: Delete Records ===\n"); - const client = await getFundedClient(); - const { output: trail } = await client + // `admin` creates the trail and sets up the role. + // `recordMaintainer` adds and deletes records. + const admin = await getFundedClient(); + const recordMaintainer = await getFundedClient(); + + const { output: trail } = await admin .createTrail() .withTrailMetadata("Delete Records Example", "Trail configured to demonstrate record deletions") .withUpdatableMetadata("Status: Active") @@ -33,56 +44,54 @@ export async function deleteRecords(): Promise { .withInitialRecordString("Seed record", "v0") .finish() .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); + .buildAndExecute(admin); + const trailId = trail.id; - const trailHandle = client.trail(trailId); - // Create a role with delete permissions - const role = trailHandle.access().forRole("RecordMaintenance"); + // Create a role with delete permissions and issue to recordMaintainer. + const role = admin.trail(trailId).access().forRole("RecordMaintenance"); await role .create(new PermissionSet([Permission.AddRecord, Permission.DeleteRecord, Permission.DeleteAllRecords])) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); + .buildAndExecute(admin); await role - .issueCapability(new CapabilityIssueOptions(client.senderAddress())) + .issueCapability(new CapabilityIssueOptions(recordMaintainer.senderAddress())) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); + .buildAndExecute(admin); + + const records = recordMaintainer.trail(trailId).records(); - // Add records - const rec1 = await trailHandle - .records() + // RecordMaintainer adds records. + const rec1 = await records .add(Data.fromString("First record"), "v1") .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); - const rec2 = await trailHandle - .records() + .buildAndExecute(recordMaintainer); + const rec2 = await records .add(Data.fromString("Second record"), "v2") .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); + .buildAndExecute(recordMaintainer); console.log("Added records", rec1.output.sequenceNumber, "and", rec2.output.sequenceNumber); - // Delete a single record - const deleted = await trailHandle - .records() + // Delete a single record. + const deleted = await records .delete(rec1.output.sequenceNumber) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); + .buildAndExecute(recordMaintainer); console.log("Deleted record", deleted.output.sequenceNumber); - let count = await trailHandle.records().recordCount(); + let count = await records.recordCount(); console.log("Record count after single delete:", count); assert.equal(count, 2n); // seed + rec2 - // Batch-delete remaining - const batchDeleted = await trailHandle - .records() + // Batch-delete remaining records. + const batchDeleted = await records .deleteBatch(BigInt(10)) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); + .buildAndExecute(recordMaintainer); console.log("Batch deleted", batchDeleted.output, "records"); - count = await trailHandle.records().recordCount(); + count = await records.recordCount(); assert.equal(count, 0n, "all records should be deleted after batch"); console.log("Record count after batch delete:", count); } diff --git a/bindings/wasm/audit_trail_wasm/examples/src/07_access_read_only_methods.ts b/bindings/wasm/audit_trail_wasm/examples/src/07_access_read_only_methods.ts index 44c7d6cb..8086199f 100644 --- a/bindings/wasm/audit_trail_wasm/examples/src/07_access_read_only_methods.ts +++ b/bindings/wasm/audit_trail_wasm/examples/src/07_access_read_only_methods.ts @@ -1,6 +1,20 @@ // Copyright 2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +/** + * ## Actors + * + * - **Admin**: Creates the trail and sets up the RecordAdmin role. + * - **RecordAdmin**: Adds one follow-up record. All subsequent operations are read-only + * and can be performed by any address — no capability required. + * + * Demonstrates how to: + * 1. Load the full on-chain trail object. + * 2. Inspect metadata, roles, and locking configuration. + * 3. Read records individually and through pagination. + * 4. Query the record-count and lock-status helpers. + */ + import { CapabilityIssueOptions, Data, @@ -12,18 +26,15 @@ import { import { strict as assert } from "assert"; import { getFundedClient, TEST_GAS_BUDGET } from "./util"; -/** - * Demonstrates how to: - * 1. Load the full on-chain trail object. - * 2. Inspect metadata, roles, and locking configuration. - * 3. Read records individually and through pagination. - * 4. Query the record-count and lock-status helpers. - */ export async function accessReadOnlyMethods(): Promise { console.log("=== Audit Trail: Read-Only Inspection ===\n"); - const client = await getFundedClient(); - const { output: created } = await client + // `admin` creates the trail and sets up the role. + // `recordAdmin` adds the follow-up record. + const admin = await getFundedClient(); + const recordAdmin = await getFundedClient(); + + const { output: created } = await admin .createTrail() .withTrailMetadata("Operations Trail", "Used to inspect read-only accessors") .withUpdatableMetadata("Status: Active") @@ -33,28 +44,31 @@ export async function accessReadOnlyMethods(): Promise { .withInitialRecordString("Initial record", "event:created") .finish() .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); + .buildAndExecute(admin); const trailId = created.id; - const trailHandle = client.trail(trailId); - // Create RecordAdmin role - const role = trailHandle.access().forRole("RecordAdmin"); - await role.create(PermissionSet.recordAdminPermissions()).withGasBudget(TEST_GAS_BUDGET).buildAndExecute(client); + // Create RecordAdmin role and issue to recordAdmin. + const role = admin.trail(trailId).access().forRole("RecordAdmin"); + await role + .create(PermissionSet.recordAdminPermissions()) + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(admin); await role - .issueCapability(new CapabilityIssueOptions(client.senderAddress())) + .issueCapability(new CapabilityIssueOptions(recordAdmin.senderAddress())) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); + .buildAndExecute(admin); - // Add a follow-up record - await trailHandle + // RecordAdmin adds a follow-up record. + await recordAdmin + .trail(trailId) .records() .add(Data.fromString("Follow-up record"), "event:updated") .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); + .buildAndExecute(recordAdmin); - // Read the full on-chain trail - const onChain = await trailHandle.get(); + // All reads below require no capability — any address can inspect the trail. + const onChain = await admin.trail(trailId).get(); console.log("Trail summary:"); console.log(" id =", onChain.id); console.log(" creator =", onChain.creator); @@ -66,7 +80,7 @@ export async function accessReadOnlyMethods(): Promise { console.log("Roles:", onChain.roles.roles.map((r) => r.name)); console.log("Locking config:", onChain.lockingConfig, "\n"); - // Query helpers + const trailHandle = admin.trail(trailId); const count = await trailHandle.records().recordCount(); const initialRecord = await trailHandle.records().get(0n); const firstPage = await trailHandle.records().listPage(undefined, 10); diff --git a/bindings/wasm/audit_trail_wasm/examples/src/08_delete_audit_trail.ts b/bindings/wasm/audit_trail_wasm/examples/src/08_delete_audit_trail.ts index fb9003f2..2e594d09 100644 --- a/bindings/wasm/audit_trail_wasm/examples/src/08_delete_audit_trail.ts +++ b/bindings/wasm/audit_trail_wasm/examples/src/08_delete_audit_trail.ts @@ -1,45 +1,60 @@ // Copyright 2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -import { CapabilityIssueOptions, Data, Permission, PermissionSet } from "@iota/audit-trail/node"; -import { strict as assert } from "assert"; -import { getFundedClient, TEST_GAS_BUDGET } from "./util"; - /** + * ## Actors + * + * - **Admin**: Creates the trail and sets up the MaintenanceAdmin role. + * - **MaintenanceAdmin**: Holds delete permissions. Attempts (and fails) to delete the + * non-empty trail, then batch-deletes all records before removing the trail itself. + * * Demonstrates how to: * 1. Show that a non-empty trail cannot be deleted. * 2. Empty the trail with deleteBatch. * 3. Delete the trail once its records are gone. */ + +import { CapabilityIssueOptions, Data, Permission, PermissionSet } from "@iota/audit-trail/node"; +import { strict as assert } from "assert"; +import { getFundedClient, TEST_GAS_BUDGET } from "./util"; + export async function deleteAuditTrail(): Promise { console.log("=== Audit Trail: Delete Trail ===\n"); - const client = await getFundedClient(); - const { output: created } = await client + // `admin` creates the trail and sets up the role. + // `maintenanceAdmin` empties and deletes the trail. + const admin = await getFundedClient(); + const maintenanceAdmin = await getFundedClient(); + + const { output: created } = await admin .createTrail() .withInitialRecordString("Initial record", "event:created") .finish() .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); + .buildAndExecute(admin); const trailId = created.id; - const trailHandle = client.trail(trailId); - // Create a role with delete permissions - const role = trailHandle.access().forRole("MaintenanceAdmin"); + // Create a role with delete permissions and issue to maintenanceAdmin. + const role = admin.trail(trailId).access().forRole("MaintenanceAdmin"); await role .create(new PermissionSet([Permission.DeleteAllRecords, Permission.DeleteAuditTrail])) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); + .buildAndExecute(admin); await role - .issueCapability(new CapabilityIssueOptions(client.senderAddress())) + .issueCapability(new CapabilityIssueOptions(maintenanceAdmin.senderAddress())) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); + .buildAndExecute(admin); + + const maintenanceTrail = maintenanceAdmin.trail(trailId); - // 1. Attempting to delete a non-empty trail should fail + // 1. Attempting to delete a non-empty trail should fail. let deleteWhileNonEmptySucceeded = false; try { - await trailHandle.deleteAuditTrail().withGasBudget(TEST_GAS_BUDGET).buildAndExecute(client); + await maintenanceTrail + .deleteAuditTrail() + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(maintenanceAdmin); deleteWhileNonEmptySucceeded = true; } catch { // Expected @@ -47,29 +62,29 @@ export async function deleteAuditTrail(): Promise { assert.equal(deleteWhileNonEmptySucceeded, false, "a trail must be empty before deletion"); console.log("Deleting the non-empty trail failed as expected.\n"); - // 2. Batch-delete all records - const deletedRecords = await trailHandle + // 2. Batch-delete all records. + const deletedRecords = await maintenanceTrail .records() .deleteBatch(BigInt(10)) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); + .buildAndExecute(maintenanceAdmin); console.log("Deleted", deletedRecords.output, "record(s) before trail removal.\n"); - const count = await trailHandle.records().recordCount(); + const count = await maintenanceTrail.records().recordCount(); assert.equal(count, 0n, "trail should have no records after batch delete"); - // 3. Delete the now-empty trail - const deletedTrail = await trailHandle + // 3. Delete the now-empty trail. + const deletedTrail = await maintenanceTrail .deleteAuditTrail() .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); + .buildAndExecute(maintenanceAdmin); console.log("Trail deleted:"); console.log(" trail_id =", deletedTrail.output.trailId); console.log(" timestamp =", deletedTrail.output.timestamp); let getAfterDeleteSucceeded = false; try { - await trailHandle.get(); + await maintenanceTrail.get(); getAfterDeleteSucceeded = true; } catch { // Expected diff --git a/bindings/wasm/audit_trail_wasm/examples/src/advanced/09_tagged_records.ts b/bindings/wasm/audit_trail_wasm/examples/src/advanced/09_tagged_records.ts index 6b35c75c..57b8e87a 100644 --- a/bindings/wasm/audit_trail_wasm/examples/src/advanced/09_tagged_records.ts +++ b/bindings/wasm/audit_trail_wasm/examples/src/advanced/09_tagged_records.ts @@ -1,17 +1,25 @@ // Copyright 2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -import { CapabilityIssueOptions, Data, Permission, PermissionSet, RoleTags } from "@iota/audit-trail/node"; -import { strict as assert } from "assert"; -import { getFundedClient, TEST_GAS_BUDGET } from "../util"; - /** + * ## Actors + * + * - **Admin**: Creates the trail, defines the FinanceWriter role restricted to the + * `finance` tag, and issues a capability bound to `financeWriter`'s address. + * - **FinanceWriter**: Holds the address-bound capability. Can add `finance`-tagged + * records but is blocked from writing `legal`-tagged records. + * * Demonstrates how to: * 1. Create a trail with a predefined tag registry. * 2. Define a role that is restricted to one record tag. * 3. Issue a capability bound to a specific wallet address. * 4. Show that the holder can add only records matching the allowed tag. */ + +import { CapabilityIssueOptions, Data, Permission, PermissionSet, RoleTags } from "@iota/audit-trail/node"; +import { strict as assert } from "assert"; +import { getFundedClient, TEST_GAS_BUDGET } from "../util"; + export async function taggedRecords(): Promise { console.log("=== Audit Trail Advanced: Tagged Records ===\n"); @@ -28,7 +36,7 @@ export async function taggedRecords(): Promise { const trailId = created.id; - // Create a role restricted to the "finance" tag + // Create a role restricted to the "finance" tag. const role = admin.trail(trailId).access().forRole("FinanceWriter"); await role .create(new PermissionSet([Permission.AddRecord]), new RoleTags(["finance"])) @@ -48,9 +56,10 @@ export async function taggedRecords(): Promise { "\n", ); + // The client automatically finds the capability in financeWriter's wallet. const financeRecords = financeWriter.trail(trailId).records(); - // Add a record with the allowed tag + // Add a record with the allowed tag. const added = await financeRecords .add(Data.fromString("Invoice approved"), "department:finance", "finance") .withGasBudget(TEST_GAS_BUDGET) @@ -58,7 +67,7 @@ export async function taggedRecords(): Promise { console.log("Added tagged record at sequence number", added.output.sequenceNumber, "with tag \"finance\".\n"); - // Attempt to add a record with a different tag — should fail + // Attempt to add a record with a different tag — should fail. let wrongTagSucceeded = false; try { await financeRecords diff --git a/bindings/wasm/audit_trail_wasm/examples/src/advanced/10_capability_constraints.ts b/bindings/wasm/audit_trail_wasm/examples/src/advanced/10_capability_constraints.ts index 47c8df7d..4f59c46f 100644 --- a/bindings/wasm/audit_trail_wasm/examples/src/advanced/10_capability_constraints.ts +++ b/bindings/wasm/audit_trail_wasm/examples/src/advanced/10_capability_constraints.ts @@ -1,16 +1,26 @@ // Copyright 2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -import { CapabilityIssueOptions, Data, PermissionSet } from "@iota/audit-trail/node"; -import { strict as assert } from "assert"; -import { createTrailWithSeedRecord, getFundedClient, TEST_GAS_BUDGET } from "../util"; - /** + * ## Actors + * + * - **Admin**: Creates the trail, defines the RecordAdmin role, and issues a capability + * bound specifically to `intendedWriter`'s address. Also performs revocation. + * - **IntendedWriter**: The authorised holder. Writes a record successfully before + * revocation, then is blocked after the capability is revoked. + * - **WrongWriter**: An unauthorised actor who attempts to use the address-bound capability. + * All write attempts are rejected by the Move contract. + * * Demonstrates how to: * 1. Bind a capability to a specific wallet address. * 2. Show that a different wallet cannot use it. * 3. Revoke the capability and confirm the bound holder can no longer use it. */ + +import { CapabilityIssueOptions, Data, PermissionSet } from "@iota/audit-trail/node"; +import { strict as assert } from "assert"; +import { createTrailWithSeedRecord, getFundedClient, TEST_GAS_BUDGET } from "../util"; + export async function capabilityConstraints(): Promise { console.log("=== Audit Trail Advanced: Capability Constraints ===\n"); @@ -21,7 +31,7 @@ export async function capabilityConstraints(): Promise { const { output: created } = await createTrailWithSeedRecord(admin); const trailId = created.id; - // Create a RecordAdmin role + // Create a RecordAdmin role. await admin .trail(trailId) .access() @@ -30,7 +40,7 @@ export async function capabilityConstraints(): Promise { .withGasBudget(TEST_GAS_BUDGET) .buildAndExecute(admin); - // Issue a capability bound to the intended writer's address + // Issue a capability bound to the intended writer's address. const issued = await admin .trail(trailId) .access() @@ -41,7 +51,7 @@ export async function capabilityConstraints(): Promise { console.log("Issued capability", issued.output.capabilityId, "to", intendedWriter.senderAddress(), "\n"); - // The wrong wallet should not be able to add a record + // The wrong wallet should not be able to add a record. let wrongWriterSucceeded = false; try { await wrongWriter @@ -56,7 +66,7 @@ export async function capabilityConstraints(): Promise { } assert.equal(wrongWriterSucceeded, false, "a capability bound to another address must not be usable"); - // The intended writer CAN add a record + // The intended writer CAN add a record. const added = await intendedWriter .trail(trailId) .records() @@ -66,7 +76,7 @@ export async function capabilityConstraints(): Promise { console.log("Bound holder added record", added.output.sequenceNumber, "successfully.\n"); - // Revoke the capability + // Revoke the capability. await admin .trail(trailId) .access() @@ -74,7 +84,7 @@ export async function capabilityConstraints(): Promise { .withGasBudget(TEST_GAS_BUDGET) .buildAndExecute(admin); - // The intended writer should no longer be able to add a record + // The intended writer should no longer be able to add a record. let revokedSucceeded = false; try { await intendedWriter diff --git a/bindings/wasm/audit_trail_wasm/examples/src/advanced/11_manage_record_tags.ts b/bindings/wasm/audit_trail_wasm/examples/src/advanced/11_manage_record_tags.ts index beb05745..3b1f6b78 100644 --- a/bindings/wasm/audit_trail_wasm/examples/src/advanced/11_manage_record_tags.ts +++ b/bindings/wasm/audit_trail_wasm/examples/src/advanced/11_manage_record_tags.ts @@ -1,86 +1,101 @@ // Copyright 2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -import { CapabilityIssueOptions, Data, PermissionSet, RoleTags } from "@iota/audit-trail/node"; -import { strict as assert } from "assert"; -import { getFundedClient, TEST_GAS_BUDGET } from "../util"; - /** + * ## Actors + * + * - **Admin**: Creates the trail and manages roles. + * - **TagAdmin**: Holds the TagAdmin capability. Adds and removes entries from the trail's + * tag registry. + * - **FinanceWriter**: Holds a `finance`-scoped RecordAdmin capability. Writes a + * `finance`-tagged record that keeps the `finance` tag in use and therefore unremovable. + * * Demonstrates how to: * 1. Delegate record-tag registry management to a TagAdmin role. * 2. Add and remove tags from the trail registry. * 3. Show that tags still in use by roles or records cannot be removed. */ + +import { CapabilityIssueOptions, Data, PermissionSet, RoleTags } from "@iota/audit-trail/node"; +import { strict as assert } from "assert"; +import { getFundedClient, TEST_GAS_BUDGET } from "../util"; + export async function manageRecordTags(): Promise { console.log("=== Audit Trail Advanced: Manage Record Tags ===\n"); - const client = await getFundedClient(); + // `admin` creates the trail and manages roles. + // `tagAdmin` adds/removes tags; `financeWriter` writes tagged records. + const admin = await getFundedClient(); + const tagAdmin = await getFundedClient(); + const financeWriter = await getFundedClient(); - const { output: created } = await client + const { output: created } = await admin .createTrail() .withRecordTags(["finance"]) .withInitialRecordString("Trail created") .finish() .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); + .buildAndExecute(admin); const trailId = created.id; - const trailHandle = client.trail(trailId); - // Delegate tag management to a TagAdmin role - const tagAdminRole = trailHandle.access().forRole("TagAdmin"); + // Delegate tag management to a TagAdmin role. + const tagAdminRole = admin.trail(trailId).access().forRole("TagAdmin"); await tagAdminRole .create(PermissionSet.tagAdminPermissions()) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); + .buildAndExecute(admin); await tagAdminRole - .issueCapability(new CapabilityIssueOptions(client.senderAddress())) + .issueCapability(new CapabilityIssueOptions(tagAdmin.senderAddress())) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); + .buildAndExecute(admin); - // Add a new tag - await trailHandle.tags().add("legal").withGasBudget(TEST_GAS_BUDGET).buildAndExecute(client); + // TagAdmin adds a new tag. + await tagAdmin.trail(trailId).tags().add("legal").withGasBudget(TEST_GAS_BUDGET).buildAndExecute(tagAdmin); - let onChain = await trailHandle.get(); + let onChain = await admin.trail(trailId).get(); console.log("Registry after adding \"legal\":", onChain.tags.map((t) => t.tag), "\n"); assert.ok(onChain.tags.some((t) => t.tag === "finance")); assert.ok(onChain.tags.some((t) => t.tag === "legal")); - // Create a role scoped to "finance" tag - await trailHandle + // Create a role scoped to "finance" tag and issue to financeWriter. + await admin + .trail(trailId) .access() .forRole("FinanceWriter") .create(PermissionSet.recordAdminPermissions(), new RoleTags(["finance"])) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); - await trailHandle + .buildAndExecute(admin); + await admin + .trail(trailId) .access() .forRole("FinanceWriter") - .issueCapability(new CapabilityIssueOptions(client.senderAddress())) + .issueCapability(new CapabilityIssueOptions(financeWriter.senderAddress())) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); + .buildAndExecute(admin); - // Add a record using the "finance" tag - await trailHandle + // FinanceWriter adds a record using the "finance" tag. + await financeWriter + .trail(trailId) .records() .add(Data.fromString("Tagged finance entry"), undefined, "finance") .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); + .buildAndExecute(financeWriter); - // Attempt to remove "finance" tag — should fail because it's in use + // TagAdmin attempts to remove "finance" tag — should fail because it's in use. let removeFinanceSucceeded = false; try { - await trailHandle.tags().remove("finance").withGasBudget(TEST_GAS_BUDGET).buildAndExecute(client); + await tagAdmin.trail(trailId).tags().remove("finance").withGasBudget(TEST_GAS_BUDGET).buildAndExecute(tagAdmin); removeFinanceSucceeded = true; } catch { // Expected } assert.equal(removeFinanceSucceeded, false, "a tag referenced by a role or record must not be removable"); - // Remove "legal" tag — should succeed because nothing uses it - await trailHandle.tags().remove("legal").withGasBudget(TEST_GAS_BUDGET).buildAndExecute(client); + // TagAdmin removes "legal" tag — should succeed because nothing uses it. + await tagAdmin.trail(trailId).tags().remove("legal").withGasBudget(TEST_GAS_BUDGET).buildAndExecute(tagAdmin); - onChain = await trailHandle.get(); + onChain = await admin.trail(trailId).get(); console.log("Registry after removing \"legal\":", onChain.tags.map((t) => t.tag), "\n"); assert.ok(onChain.tags.some((t) => t.tag === "finance"), "finance tag should still exist"); assert.ok(!onChain.tags.some((t) => t.tag === "legal"), "legal tag should be removed"); 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 0b8a4f7f..94cae7fc 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 @@ -1,6 +1,35 @@ // Copyright 2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +/** + * # Customs Clearance Example + * + * Models a customs-clearance process for a single shipment. + * + * ## Actors + * + * - **Admin**: Creates the trail and sets up all roles and capabilities. + * - **DocsOperator**: Handles document submission (invoices, packing lists). Writes only + * `documents`-tagged records. + * - **ExportBroker**: Files export declarations and records clearance decisions at the origin. + * Writes only `export`-tagged records. + * - **ImportBroker**: Handles duty assessment and import clearance at the destination. + * Writes only `import`-tagged records. + * - **Inspector**: Records the outcome of a customs physical inspection. Writes only + * `inspection`-tagged records; the role is created mid-process when an inspection is triggered. + * - **Supervisor**: Updates the mutable trail metadata (processing status). No record-write + * permissions. + * - **LockingAdmin**: Freezes the trail once the shipment is fully cleared. + * + * ## How the trail is used + * + * - immutable_metadata: shipment and declaration identity + * - updatable_metadata: current customs-processing status + * - record tags: documents, export, import, inspection + * - roles and capabilities: each operational role writes only the events it owns + * - locking: writes are frozen once the shipment is fully cleared + */ + import { AuditTrailClient, CapabilityIssueOptions, @@ -11,29 +40,26 @@ import { RoleTags, TimeLock, } from "@iota/audit-trail/node"; +import { createHash } from "crypto"; import { strict as assert } from "assert"; import { getFundedClient, TEST_GAS_BUDGET } from "../util"; -/** - * # Customs Clearance Example - * - * Models a customs-clearance process for a single shipment. - * - * - immutable_metadata: shipment and declaration identity - * - updatable_metadata: current customs-processing status - * - record tags: documents, export, import, inspection - * - roles and capabilities: each operational role writes only the events it owns - * - locking: writes are frozen once the shipment is fully cleared - */ export async function customsClearance(): Promise { console.log("=== Customs Clearance ===\n"); - const client = await getFundedClient(); + const admin = await getFundedClient(); + const docsOperator = await getFundedClient(); + const exportBroker = await getFundedClient(); + const importBroker = await getFundedClient(); + const supervisor = await getFundedClient(); + const lockingAdmin = await getFundedClient(); + const inspector = await getFundedClient(); + + // === Create the customs-clearance trail === - // 1. Create the trail console.log("Creating a customs-clearance trail..."); - const { output: created } = await client + const { output: created } = await admin .createTrail() .withRecordTags(["documents", "export", "import", "inspection"]) .withTrailMetadata( @@ -47,65 +73,70 @@ export async function customsClearance(): Promise { .withInitialRecordString("Customs clearance case opened for inbound shipment", "event:case_opened", "documents") .finish() .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); + .buildAndExecute(admin); const trailId = created.id; - // 2. Create tag-scoped roles - await issueTaggedRecordRole(client, trailId, "DocsOperator", "documents"); - await issueTaggedRecordRole(client, trailId, "ExportBroker", "export"); - await issueTaggedRecordRole(client, trailId, "ImportBroker", "import"); + // === Set up roles and capabilities for each actor === - // Supervisor can update metadata - await client + await issueTaggedRecordRole(admin, trailId, "DocsOperator", "documents", docsOperator.senderAddress()); + await issueTaggedRecordRole(admin, trailId, "ExportBroker", "export", exportBroker.senderAddress()); + await issueTaggedRecordRole(admin, trailId, "ImportBroker", "import", importBroker.senderAddress()); + + // Supervisor can update metadata. + await admin .trail(trailId) .access() .forRole("Supervisor") .create(PermissionSet.metadataAdminPermissions()) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); - await client + .buildAndExecute(admin); + await admin .trail(trailId) .access() .forRole("Supervisor") - .issueCapability(new CapabilityIssueOptions(client.senderAddress())) + .issueCapability(new CapabilityIssueOptions(supervisor.senderAddress())) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); + .buildAndExecute(admin); - // LockingAdmin can manage locking - await client + // LockingAdmin can manage locking. + await admin .trail(trailId) .access() .forRole("LockingAdmin") .create(PermissionSet.lockingAdminPermissions()) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); - await client + .buildAndExecute(admin); + await admin .trail(trailId) .access() .forRole("LockingAdmin") - .issueCapability(new CapabilityIssueOptions(client.senderAddress())) + .issueCapability(new CapabilityIssueOptions(lockingAdmin.senderAddress())) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); + .buildAndExecute(admin); + + // === Document submission === - // 3. Upload documents - const docsUploaded = await client + // Documents are stored off-chain in an access-controlled environment (e.g. a TWIN node). + // Only the SHA-256 fingerprint is committed on-chain for tamper-evidence. + const invoiceHash = createHash("sha256").update("invoice-SHP-2026-CLEAR-001-v1.pdf").digest(); + const docsUploaded = await docsOperator .trail(trailId) .records() - .add(Data.fromString("Commercial invoice and packing list uploaded"), "event:documents_uploaded", "documents") + .add(Data.fromBytes(invoiceHash), "event:documents_uploaded", "documents") .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); + .buildAndExecute(docsOperator); console.log("Docs operator added record", docsUploaded.output.sequenceNumber + ".\n"); - // 4. Update metadata — awaiting export clearance - await client + await supervisor .trail(trailId) .updateMetadata("Status: Awaiting Export Clearance") .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); + .buildAndExecute(supervisor); - // 5. Export clearance - const exportFiled = await client + // === Export clearance === + + const exportFiled = await exportBroker .trail(trailId) .records() .add( @@ -114,14 +145,14 @@ export async function customsClearance(): Promise { "export", ) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); + .buildAndExecute(exportBroker); - const exportCleared = await client + const exportCleared = await exportBroker .trail(trailId) .records() .add(Data.fromString("Export clearance granted by Hamburg customs office"), "event:export_cleared", "export") .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); + .buildAndExecute(exportBroker); console.log( "Export broker added records", @@ -130,17 +161,19 @@ export async function customsClearance(): Promise { exportCleared.output.sequenceNumber + ".\n", ); - // 6. Update metadata — awaiting import clearance - await client + await supervisor .trail(trailId) .updateMetadata("Status: Awaiting Import Clearance") .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); + .buildAndExecute(supervisor); + + // === Inspection gate === - // 7. Attempt an inspection write before the inspector role exists + // The import broker does not hold an inspection-scoped capability at this point. + // The write attempt must fail to prove that tag-based access control is enforced. let inspectionDenied = false; try { - await client + await importBroker .trail(trailId) .records() .add( @@ -149,7 +182,7 @@ export async function customsClearance(): Promise { "inspection", ) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); + .buildAndExecute(importBroker); inspectionDenied = true; } catch { // Expected @@ -161,10 +194,10 @@ export async function customsClearance(): Promise { ); console.log("Inspection write was correctly denied before the inspector role existed.\n"); - // 8. Create inspector role and add inspection record - await issueTaggedRecordRole(client, trailId, "Inspector", "inspection"); + // A customs inspection is triggered; the inspector role is created and issued mid-process. + await issueTaggedRecordRole(admin, trailId, "Inspector", "inspection", inspector.senderAddress()); - const inspectionDone = await client + const inspectionDone = await inspector .trail(trailId) .records() .add( @@ -173,23 +206,24 @@ export async function customsClearance(): Promise { "inspection", ) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); + .buildAndExecute(inspector); console.log("Inspector added record", inspectionDone.output.sequenceNumber + ".\n"); - // 9. Import clearance - const dutyAssessed = await client + // === Import clearance === + + const dutyAssessed = await importBroker .trail(trailId) .records() .add(Data.fromString("Import duty assessed and paid"), "event:duty_assessed", "import") .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); + .buildAndExecute(importBroker); - const importCleared = await client + const importCleared = await importBroker .trail(trailId) .records() .add(Data.fromString("Import clearance granted by Nairobi customs"), "event:import_cleared", "import") .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); + .buildAndExecute(importBroker); console.log( "Import broker added records", @@ -198,41 +232,39 @@ export async function customsClearance(): Promise { importCleared.output.sequenceNumber + ".\n", ); - // 10. Mark as cleared - await client + await supervisor .trail(trailId) .updateMetadata("Status: Cleared") .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); + .buildAndExecute(supervisor); + + // === Final lock and verification === - // 11. Freeze writes - await client + await lockingAdmin .trail(trailId) .locking() .updateWriteLock(TimeLock.withInfinite()) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); + .buildAndExecute(lockingAdmin); - const afterLock = await client.trail(trailId).get(); + const afterLock = await admin.trail(trailId).get(); console.log("Write lock after clearance:", afterLock.lockingConfig.writeLock, "\n"); - // 12. Verify that late writes are rejected let lateWriteSucceeded = false; try { - await client + await docsOperator .trail(trailId) .records() .add(Data.fromString("Late customs note after the case was closed"), "event:late_note", "documents") .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); + .buildAndExecute(docsOperator); lateWriteSucceeded = true; } catch { // Expected } assert.equal(lateWriteSucceeded, false, "cleared customs trail should reject late writes after the final lock"); - // 13. List all records - const firstPage = await client.trail(trailId).records().listPage(undefined, 20); + const firstPage = await admin.trail(trailId).records().listPage(undefined, 20); console.log("Recorded customs events:"); for (const record of firstPage.records) { console.log(` #${record.sequenceNumber} | ${record.data} | tag=${record.tag} | ${record.metadata}`); @@ -240,30 +272,31 @@ export async function customsClearance(): Promise { assert.equal(firstPage.records.length, 7, "expected 7 customs records including the initial case-opened record"); - const trailState = await client.trail(trailId).get(); + const trailState = await admin.trail(trailId).get(); assert.equal(trailState.updatableMetadata, "Status: Cleared", "customs case should finish in cleared state"); console.log("\nCustoms clearance completed successfully."); } async function issueTaggedRecordRole( - client: AuditTrailClient, + admin: AuditTrailClient, trailId: string, roleName: string, tag: string, + issuedTo: string, ): Promise { - await client + await admin .trail(trailId) .access() .forRole(roleName) .create(PermissionSet.recordAdminPermissions(), new RoleTags([tag])) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); - await client + .buildAndExecute(admin); + await admin .trail(trailId) .access() .forRole(roleName) - .issueCapability(new CapabilityIssueOptions(client.senderAddress())) + .issueCapability(new CapabilityIssueOptions(issuedTo)) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); + .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 5357798a..597602a7 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 @@ -1,25 +1,28 @@ // Copyright 2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -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"; - /** * # Clinical Trial Data-Integrity Example * * Models a Phase III clinical trial where an immutable audit trail * guarantees data integrity, role-scoped access, and time-constrained oversight. * + * ## Actors + * + * - **Admin**: Creates the trail and sets up all roles and capabilities. + * - **Enroller**: Writes enrollment events. Restricted to the `enrollment` tag. + * - **SafetyOfficer**: Records adverse events and safety observations. Restricted to `safety`. + * - **EfficacyReviewer**: Records treatment outcomes. Restricted to `efficacy`. + * - **PkAnalyst**: Records pharmacokinetic results. Restricted to the `pk` tag that is added + * mid-study when a PK sub-study is initiated. + * - **Monitor**: Updates the mutable study-phase metadata. Access is time-windowed to the + * active study period (90 days from now). + * - **DataSafetyBoard**: Controls write and delete locks. Freezes the dataset after review. + * - **Regulator**: Read-only verifier. In production this would use `AuditTrailClientReadOnly` + * (no signing key); here a funded client is used to keep the example self-contained. + * + * ## How the trail is used + * * - immutable_metadata: protocol identity and study description * - updatable_metadata: current study phase (updated as the trial progresses) * - record tags: enrollment, safety, efficacy, pk (added mid-study) @@ -29,15 +32,37 @@ import { getFundedClient, TEST_GAS_BUDGET } from "../util"; * dataset after the Data Safety Board completes its review * - read-only verification: a regulator inspects the trail without write access */ + +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"; + export async function clinicalTrial(): Promise { console.log("=== Clinical Trial Data Integrity ===\n"); - const client = await getFundedClient(); + const admin = await getFundedClient(); + const enroller = await getFundedClient(); + const safetyOfficer = await getFundedClient(); + const efficacyReviewer = await getFundedClient(); + const pkAnalyst = await getFundedClient(); + const monitor = await getFundedClient(); + const dataSafetyBoard = await getFundedClient(); + const regulator = await getFundedClient(); + + // === Create the clinical-trial trail === - // 1. Create the trial trail console.log("Creating the clinical-trial audit trail..."); - const { output: created } = await client + const { output: created } = await admin .createTrail() .withRecordTags(["enrollment", "safety", "efficacy"]) .withTrailMetadata( @@ -55,78 +80,85 @@ export async function clinicalTrial(): Promise { ) .finish() .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); + .buildAndExecute(admin); const trailId = created.id; console.log("Trail created with ID", trailId, "\n"); - // 2. Define roles with tag-scoped permissions + // === Define roles with tag-scoped permissions === + console.log("Defining study roles..."); - await issueTaggedRecordRole(client, trailId, "Enroller", "enrollment"); - await issueTaggedRecordRole(client, trailId, "SafetyOfficer", "safety"); - await issueTaggedRecordRole(client, trailId, "EfficacyReviewer", "efficacy"); + await issueTaggedRecordRole(admin, trailId, "Enroller", "enrollment", enroller.senderAddress()); + await issueTaggedRecordRole(admin, trailId, "SafetyOfficer", "safety", safetyOfficer.senderAddress()); + await issueTaggedRecordRole(admin, trailId, "EfficacyReviewer", "efficacy", efficacyReviewer.senderAddress()); - // Monitor can update metadata (study phase) — valid for 90 days - await client + // Monitor can update metadata (study phase) — valid for 90 days. + await admin .trail(trailId) .access() .forRole("Monitor") .create(PermissionSet.metadataAdminPermissions()) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); + .buildAndExecute(admin); const nowMs = BigInt(Date.now()); const studyEndMs = nowMs + BigInt(90 * 24 * 60 * 60 * 1000); - await client + await admin .trail(trailId) .access() .forRole("Monitor") - .issueCapability(new CapabilityIssueOptions(client.senderAddress(), nowMs, studyEndMs)) + .issueCapability(new CapabilityIssueOptions(monitor.senderAddress(), nowMs, studyEndMs)) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); + .buildAndExecute(admin); console.log("Monitor capability issued (expires at timestamp", studyEndMs + ")\n"); - // Data Safety Board can manage locking - await client + // Data Safety Board can manage locking. + await admin .trail(trailId) .access() .forRole("DataSafetyBoard") .create(PermissionSet.lockingAdminPermissions()) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); - await client + .buildAndExecute(admin); + await admin .trail(trailId) .access() .forRole("DataSafetyBoard") - .issueCapability(new CapabilityIssueOptions(client.senderAddress())) + .issueCapability(new CapabilityIssueOptions(dataSafetyBoard.senderAddress())) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); + .buildAndExecute(admin); + + // === Enrollment phase === - // 3. Enrollment phase console.log("--- Enrollment Phase ---"); - const enrolled = await client + const enrolled = await enroller .trail(trailId) .records() .add(Data.fromString("Patient P-101 enrolled at Site Hamburg"), "event:patient_enrolled", "enrollment") .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); + .buildAndExecute(enroller); console.log("Enroller added record", enrolled.output.sequenceNumber + ".\n"); - // 4. Safety and efficacy records + // === Study data collection === + console.log("--- Study Data Collection ---"); - const safetyEvent = await client + const safetyEvent = await safetyOfficer .trail(trailId) .records() - .add(Data.fromString("Adverse event: mild headache reported by Patient P-101"), "event:adverse_event", "safety") + .add( + Data.fromString("Adverse event: mild headache reported by Patient P-101"), + "event:adverse_event", + "safety", + ) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); + .buildAndExecute(safetyOfficer); - const efficacyRecord = await client + const efficacyRecord = await efficacyReviewer .trail(trailId) .records() .add( @@ -135,7 +167,7 @@ export async function clinicalTrial(): Promise { "efficacy", ) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); + .buildAndExecute(efficacyReviewer); console.log( "SafetyOfficer added record", @@ -144,98 +176,98 @@ export async function clinicalTrial(): Promise { efficacyRecord.output.sequenceNumber + ".\n", ); - // 5. Add a new tag mid-study (pharmacokinetics) + // === Mid-study amendment: add pharmacokinetics tag === + console.log("--- Mid-Study Amendment ---"); - await client - .trail(trailId) - .tags() - .add("pk") - .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); + await admin.trail(trailId).tags().add("pk").withGasBudget(TEST_GAS_BUDGET).buildAndExecute(admin); console.log("Added tag \"pk\" (pharmacokinetics) to the trail."); - await issueTaggedRecordRole(client, trailId, "PkAnalyst", "pk"); + await issueTaggedRecordRole(admin, trailId, "PkAnalyst", "pk", pkAnalyst.senderAddress()); - const pkRecord = await client + const pkRecord = await pkAnalyst .trail(trailId) .records() .add(Data.fromString("PK analysis: Cmax reached at 2.4 h, half-life 8.7 h"), "event:pk_result", "pk") .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); + .buildAndExecute(pkAnalyst); console.log("PkAnalyst added record", pkRecord.output.sequenceNumber + ".\n"); - // 6. Deletion window protects recent records + // === Deletion window enforcement === + console.log("--- Deletion Window Enforcement ---"); + // The PkAnalyst has RecordAdmin permissions, but the count-based deletion window + // protects the newest 3 records, so this attempt must fail. let deleteSucceeded = false; try { - await client + await pkAnalyst .trail(trailId) .records() .delete(pkRecord.output.sequenceNumber) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); + .buildAndExecute(pkAnalyst); deleteSucceeded = true; } catch { // Expected } - assert.equal( - deleteSucceeded, - false, - "recent records must be protected by the count-based deletion window", - ); + assert.equal(deleteSucceeded, false, "recent records must be protected by the count-based deletion window"); console.log( "Record", pkRecord.output.sequenceNumber, "is within the deletion window (newest 3) and cannot be deleted.\n", ); - // 7. Monitor updates study phase metadata + // === Metadata update (Monitor) === + console.log("--- Metadata Update ---"); - await client + await monitor .trail(trailId) .updateMetadata("Phase: Data Review") .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); + .buildAndExecute(monitor); - const trail = await client.trail(trailId).get(); + const trail = await admin.trail(trailId).get(); console.log("Study phase updated to:", trail.updatableMetadata, "\n"); - // 8. Data Safety Board locks the study dataset + // === Data Safety Board locks the study dataset === + console.log("--- Data Safety Board Lock ---"); const lockUntilMs = nowMs + BigInt(365 * 24 * 60 * 60 * 1000); // 1 year from now - await client + await dataSafetyBoard .trail(trailId) .locking() .updateWriteLock(TimeLock.withUnlockAtMs(lockUntilMs)) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); + .buildAndExecute(dataSafetyBoard); console.log("Write lock set to UnlockAtMs(" + lockUntilMs + ") — writes blocked until that timestamp.\n"); - // Lock trail from deletion permanently - await client + // Lock trail from deletion permanently. + await dataSafetyBoard .trail(trailId) .locking() .updateDeleteTrailLock(TimeLock.withInfinite()) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); + .buildAndExecute(dataSafetyBoard); - const finalLocking = await client.trail(trailId).get(); + const finalLocking = await admin.trail(trailId).get(); console.log( "Delete-trail lock set to", finalLocking.lockingConfig.deleteTrailLock.type, "— trail cannot be deleted.\n", ); - // 9. Regulator read-only verification + // === Regulator read-only verification === + console.log("--- Regulator Verification ---"); - const regulatorHandle = client.trail(trailId); + // In production the regulator would use AuditTrailClientReadOnly (no signing key). + // Here a funded client is used to keep the example self-contained. + const regulatorHandle = regulator.trail(trailId); const onChain = await regulatorHandle.get(); console.log("Protocol:", onChain.immutableMetadata); @@ -249,7 +281,6 @@ export async function clinicalTrial(): Promise { console.log(` #${record.sequenceNumber} | tag=${record.tag} | ${record.metadata}`); } - // 10. Assertions assert.equal(firstPage.records.length, 5, "expected 5 records (initial + enrolled + safety + efficacy + pk)"); assert.ok(onChain.tags.some((t) => t.tag === "pk"), "the 'pk' tag must exist after mid-study amendment"); assert.equal(onChain.lockingConfig.deleteRecordWindow.type, LockingWindow.withCountBased(BigInt(3)).type); @@ -261,23 +292,24 @@ export async function clinicalTrial(): Promise { } async function issueTaggedRecordRole( - client: AuditTrailClient, + admin: AuditTrailClient, trailId: string, roleName: string, tag: string, + issuedTo: string, ): Promise { - await client + await admin .trail(trailId) .access() .forRole(roleName) .create(PermissionSet.recordAdminPermissions(), new RoleTags([tag])) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); - await client + .buildAndExecute(admin); + await admin .trail(trailId) .access() .forRole(roleName) - .issueCapability(new CapabilityIssueOptions(client.senderAddress())) + .issueCapability(new CapabilityIssueOptions(issuedTo)) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(client); + .buildAndExecute(admin); } From 032b46aa8880ea4ac2d4c18270f73d29f17d3eff Mon Sep 17 00:00:00 2001 From: Yasir Date: Fri, 10 Apr 2026 16:01:30 +0300 Subject: [PATCH 154/189] style: reformat doc comments and method chains in audit trail examples --- .../examples/src/05_manage_access.ts | 4 +- .../src/real-world/01_customs_clearance.ts | 2 +- examples/audit-trail/01_create_audit_trail.rs | 6 +-- .../audit-trail/02_add_and_read_records.rs | 4 +- examples/audit-trail/03_update_metadata.rs | 4 +- examples/audit-trail/04_configure_locking.rs | 4 +- examples/audit-trail/05_manage_access.rs | 8 ++-- examples/audit-trail/06_delete_records.rs | 4 +- .../07_access_read_only_methods.rs | 4 +- examples/audit-trail/08_delete_audit_trail.rs | 9 ++-- .../audit-trail/advanced/09_tagged_records.rs | 8 ++-- .../advanced/10_capability_constraints.rs | 12 ++--- .../advanced/11_manage_record_tags.rs | 29 ++++++++---- .../real-world/01_customs_clearance.rs | 46 +++++++++++++------ .../real-world/02_clinical_trial.rs | 32 +++++++------ 15 files changed, 106 insertions(+), 70 deletions(-) diff --git a/bindings/wasm/audit_trail_wasm/examples/src/05_manage_access.ts b/bindings/wasm/audit_trail_wasm/examples/src/05_manage_access.ts index 0107db58..9b1a98c1 100644 --- a/bindings/wasm/audit_trail_wasm/examples/src/05_manage_access.ts +++ b/bindings/wasm/audit_trail_wasm/examples/src/05_manage_access.ts @@ -55,7 +55,9 @@ export async function manageAccess(): Promise { // 3. Issue a constrained capability bound to operationsUser's address. const constrainedCap = await role - .issueCapability(new CapabilityIssueOptions(operationsUser.senderAddress(), undefined, BigInt(4_102_444_800_000))) + .issueCapability( + new CapabilityIssueOptions(operationsUser.senderAddress(), undefined, BigInt(4_102_444_800_000)), + ) .withGasBudget(TEST_GAS_BUDGET) .buildAndExecute(admin); console.log("\nIssued constrained capability:"); 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 94cae7fc..09c097ee 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 @@ -40,8 +40,8 @@ import { RoleTags, TimeLock, } from "@iota/audit-trail/node"; -import { createHash } from "crypto"; import { strict as assert } from "assert"; +import { createHash } from "crypto"; import { getFundedClient, TEST_GAS_BUDGET } from "../util"; export async function customsClearance(): Promise { diff --git a/examples/audit-trail/01_create_audit_trail.rs b/examples/audit-trail/01_create_audit_trail.rs index 9d3d8cad..b4e7c6d4 100644 --- a/examples/audit-trail/01_create_audit_trail.rs +++ b/examples/audit-trail/01_create_audit_trail.rs @@ -3,10 +3,8 @@ //! ## Actors //! -//! - **Admin**: Creates the trail and holds the built-in Admin capability that is -//! automatically minted on creation. -//! - **RecordAdmin**: Receives a RecordAdmin capability bound to their address. Writes -//! records in subsequent examples. +//! - **Admin**: Creates the trail and holds the built-in Admin capability that is automatically minted on creation. +//! - **RecordAdmin**: Receives a RecordAdmin capability bound to their address. Writes records in subsequent examples. use anyhow::Result; use audit_trail::core::types::{CapabilityIssueOptions, Data, ImmutableMetadata, InitialRecord, PermissionSet}; diff --git a/examples/audit-trail/02_add_and_read_records.rs b/examples/audit-trail/02_add_and_read_records.rs index abd2a483..903edbc8 100644 --- a/examples/audit-trail/02_add_and_read_records.rs +++ b/examples/audit-trail/02_add_and_read_records.rs @@ -4,8 +4,8 @@ //! ## Actors //! //! - **Admin**: Creates the trail, defines the RecordAdmin role, and issues a capability. -//! - **RecordAdmin**: Holds the capability and writes records. Reads are also done through -//! this client to demonstrate that any address can read, but only the cap holder can write. +//! - **RecordAdmin**: Holds the capability and writes records. Reads are also done through this client to demonstrate +//! that any address can read, but only the cap holder can write. use anyhow::{Result, ensure}; use audit_trail::core::types::{CapabilityIssueOptions, Data, InitialRecord, PermissionSet}; diff --git a/examples/audit-trail/03_update_metadata.rs b/examples/audit-trail/03_update_metadata.rs index ba1c58ed..94eac179 100644 --- a/examples/audit-trail/03_update_metadata.rs +++ b/examples/audit-trail/03_update_metadata.rs @@ -4,8 +4,8 @@ //! ## Actors //! //! - **Admin**: Creates the trail and sets up the MetadataAdmin role. -//! - **MetadataAdmin**: Holds the MetadataAdmin capability and updates the trail's mutable -//! status field. Has no record-write permissions. +//! - **MetadataAdmin**: Holds the MetadataAdmin capability and updates the trail's mutable status field. Has no +//! record-write permissions. use anyhow::{Result, ensure}; use audit_trail::core::types::{CapabilityIssueOptions, Data, ImmutableMetadata, InitialRecord, PermissionSet}; diff --git a/examples/audit-trail/04_configure_locking.rs b/examples/audit-trail/04_configure_locking.rs index cc6fad8f..c9c71caa 100644 --- a/examples/audit-trail/04_configure_locking.rs +++ b/examples/audit-trail/04_configure_locking.rs @@ -5,8 +5,8 @@ //! //! - **Admin**: Creates the trail and sets up the LockingAdmin and RecordAdmin roles. //! - **LockingAdmin**: Controls write and delete locks. Holds the LockingAdmin capability. -//! - **RecordAdmin**: Writes records. Used to demonstrate that the write lock is enforced -//! per-sender, not just checked by the admin. +//! - **RecordAdmin**: Writes records. Used to demonstrate that the write lock is enforced per-sender, not just checked +//! by the admin. use anyhow::{Result, ensure}; use audit_trail::core::types::{CapabilityIssueOptions, Data, InitialRecord, LockingWindow, PermissionSet, TimeLock}; diff --git a/examples/audit-trail/05_manage_access.rs b/examples/audit-trail/05_manage_access.rs index 666f50ed..3a6cfc5c 100644 --- a/examples/audit-trail/05_manage_access.rs +++ b/examples/audit-trail/05_manage_access.rs @@ -3,10 +3,10 @@ //! ## Actors //! -//! - **Admin**: Creates and updates roles, issues capabilities, revokes and destroys them, -//! and finally deletes the role once it is no longer needed. -//! - **OperationsUser**: The subject of all capability issuance. Capabilities are bound to -//! this address to demonstrate that revocation immediately blocks their access. +//! - **Admin**: Creates and updates roles, issues capabilities, revokes and destroys them, and finally deletes the role +//! once it is no longer needed. +//! - **OperationsUser**: The subject of all capability issuance. Capabilities are bound to this address to demonstrate +//! that revocation immediately blocks their access. use std::collections::HashSet; diff --git a/examples/audit-trail/06_delete_records.rs b/examples/audit-trail/06_delete_records.rs index 0d6c76e9..c92f409f 100644 --- a/examples/audit-trail/06_delete_records.rs +++ b/examples/audit-trail/06_delete_records.rs @@ -4,8 +4,8 @@ //! ## Actors //! //! - **Admin**: Creates the trail and sets up the RecordMaintenance role. -//! - **RecordMaintainer**: Holds the RecordMaintenance capability. Adds records and then -//! deletes them individually and in batch. +//! - **RecordMaintainer**: Holds the RecordMaintenance capability. Adds records and then deletes them individually and +//! in batch. use std::collections::HashSet; diff --git a/examples/audit-trail/07_access_read_only_methods.rs b/examples/audit-trail/07_access_read_only_methods.rs index 05923160..19151284 100644 --- a/examples/audit-trail/07_access_read_only_methods.rs +++ b/examples/audit-trail/07_access_read_only_methods.rs @@ -4,8 +4,8 @@ //! ## Actors //! //! - **Admin**: Creates the trail and sets up the RecordAdmin role. -//! - **RecordAdmin**: Adds one follow-up record. All subsequent operations are read-only -//! and can be performed by any address — no capability required. +//! - **RecordAdmin**: Adds one follow-up record. All subsequent operations are read-only and can be performed by any +//! address — no capability required. use anyhow::{Result, ensure}; use audit_trail::core::types::{ diff --git a/examples/audit-trail/08_delete_audit_trail.rs b/examples/audit-trail/08_delete_audit_trail.rs index 73258cb8..52e55e5a 100644 --- a/examples/audit-trail/08_delete_audit_trail.rs +++ b/examples/audit-trail/08_delete_audit_trail.rs @@ -4,8 +4,8 @@ //! ## Actors //! //! - **Admin**: Creates the trail and sets up the MaintenanceAdmin role. -//! - **MaintenanceAdmin**: Holds delete permissions. Attempts (and fails) to delete the -//! non-empty trail, then batch-deletes all records before removing the trail itself. +//! - **MaintenanceAdmin**: Holds delete permissions. Attempts (and fails) to delete the non-empty trail, then +//! batch-deletes all records before removing the trail itself. use std::collections::HashSet; @@ -65,7 +65,10 @@ async fn main() -> Result<()> { let maintenance_trail = maintenance_admin.trail(created.trail_id); - let delete_while_non_empty = maintenance_trail.delete_audit_trail().build_and_execute(&maintenance_admin).await; + let delete_while_non_empty = maintenance_trail + .delete_audit_trail() + .build_and_execute(&maintenance_admin) + .await; ensure!(delete_while_non_empty.is_err(), "a trail must be empty before deletion"); println!("Deleting the non-empty trail failed as expected.\n"); diff --git a/examples/audit-trail/advanced/09_tagged_records.rs b/examples/audit-trail/advanced/09_tagged_records.rs index c8238a16..4dcf243c 100644 --- a/examples/audit-trail/advanced/09_tagged_records.rs +++ b/examples/audit-trail/advanced/09_tagged_records.rs @@ -3,10 +3,10 @@ //! ## Actors //! -//! - **Admin**: Creates the trail, defines the FinanceWriter role restricted to the -//! `finance` tag, and issues a capability bound to `finance_writer`'s address. -//! - **FinanceWriter**: Holds the address-bound capability. Can add `finance`-tagged -//! records but is blocked from writing `legal`-tagged records. +//! - **Admin**: Creates the trail, defines the FinanceWriter role restricted to the `finance` tag, and issues a +//! capability bound to `finance_writer`'s address. +//! - **FinanceWriter**: Holds the address-bound capability. Can add `finance`-tagged records but is blocked from +//! writing `legal`-tagged records. use anyhow::{Result, ensure}; use audit_trail::core::types::{CapabilityIssueOptions, Data, InitialRecord, Permission, RoleTags}; diff --git a/examples/audit-trail/advanced/10_capability_constraints.rs b/examples/audit-trail/advanced/10_capability_constraints.rs index 69c1630e..d56db61f 100644 --- a/examples/audit-trail/advanced/10_capability_constraints.rs +++ b/examples/audit-trail/advanced/10_capability_constraints.rs @@ -3,12 +3,12 @@ //! ## Actors //! -//! - **Admin**: Creates the trail, defines the RecordAdmin role, and issues a capability -//! bound specifically to `intended_writer`'s address. Also performs revocation. -//! - **IntendedWriter**: The authorised holder. Writes a record successfully before -//! revocation, then is blocked after the capability is revoked. -//! - **WrongWriter**: An unauthorised actor who attempts to use the address-bound capability. -//! All write attempts are rejected by the Move contract. +//! - **Admin**: Creates the trail, defines the RecordAdmin role, and issues a capability bound specifically to +//! `intended_writer`'s address. Also performs revocation. +//! - **IntendedWriter**: The authorised holder. Writes a record successfully before revocation, then is blocked after +//! the capability is revoked. +//! - **WrongWriter**: An unauthorised actor who attempts to use the address-bound capability. All write attempts are +//! rejected by the Move contract. use anyhow::{Result, ensure}; use audit_trail::core::types::{CapabilityIssueOptions, Data, InitialRecord, PermissionSet}; diff --git a/examples/audit-trail/advanced/11_manage_record_tags.rs b/examples/audit-trail/advanced/11_manage_record_tags.rs index 6194dba1..d8c52def 100644 --- a/examples/audit-trail/advanced/11_manage_record_tags.rs +++ b/examples/audit-trail/advanced/11_manage_record_tags.rs @@ -4,11 +4,9 @@ //! ## Actors //! //! - **Admin**: Creates the trail and manages roles. -//! - **TagAdmin**: Holds the TagAdmin capability. Adds and removes entries from the trail's -//! tag registry. -//! - **FinanceWriter**: Holds a `finance`-scoped RecordAdmin capability. Writes a -//! `finance`-tagged record that keeps the `finance` tag in use and therefore -//! unremovable. +//! - **TagAdmin**: Holds the TagAdmin capability. Adds and removes entries from the trail's tag registry. +//! - **FinanceWriter**: Holds a `finance`-scoped RecordAdmin capability. Writes a `finance`-tagged record that keeps +//! the `finance` tag in use and therefore unremovable. use anyhow::{Result, ensure}; use audit_trail::core::types::{CapabilityIssueOptions, Data, InitialRecord, PermissionSet, RoleTags}; @@ -60,7 +58,12 @@ async fn main() -> Result<()> { .build_and_execute(&admin) .await?; - tag_admin.trail(trail_id).tags().add("legal").build_and_execute(&tag_admin).await?; + tag_admin + .trail(trail_id) + .tags() + .add("legal") + .build_and_execute(&tag_admin) + .await?; let after_add = admin.trail(trail_id).get().await?; println!("Registry after adding \"legal\": {:?}\n", after_add.tags.tag_map); @@ -96,13 +99,23 @@ async fn main() -> Result<()> { .build_and_execute(&finance_writer) .await?; - let remove_finance = tag_admin.trail(trail_id).tags().remove("finance").build_and_execute(&tag_admin).await; + let remove_finance = tag_admin + .trail(trail_id) + .tags() + .remove("finance") + .build_and_execute(&tag_admin) + .await; ensure!( remove_finance.is_err(), "a tag referenced by a role or record must not be removable" ); - tag_admin.trail(trail_id).tags().remove("legal").build_and_execute(&tag_admin).await?; + tag_admin + .trail(trail_id) + .tags() + .remove("legal") + .build_and_execute(&tag_admin) + .await?; let after_remove = admin.trail(trail_id).get().await?; println!("Registry after removing \"legal\": {:?}\n", after_remove.tags.tag_map); diff --git a/examples/audit-trail/real-world/01_customs_clearance.rs b/examples/audit-trail/real-world/01_customs_clearance.rs index 18e427be..6c651fa9 100644 --- a/examples/audit-trail/real-world/01_customs_clearance.rs +++ b/examples/audit-trail/real-world/01_customs_clearance.rs @@ -8,17 +8,14 @@ //! ## Actors //! //! - **Admin**: Creates the trail and sets up all roles and capabilities. -//! - **DocsOperator**: Handles document submission (invoices, packing lists). Writes only -//! `documents`-tagged records. -//! - **ExportBroker**: Files export declarations and records clearance decisions at the origin. -//! Writes only `export`-tagged records. -//! - **ImportBroker**: Handles duty assessment and import clearance at the destination. -//! Writes only `import`-tagged records. -//! - **Inspector**: Records the outcome of a customs physical inspection. Writes only -//! `inspection`-tagged records; the role is created mid-process when an inspection is -//! triggered. -//! - **Supervisor**: Updates the mutable trail metadata (processing status). No record-write -//! permissions. +//! - **DocsOperator**: Handles document submission (invoices, packing lists). Writes only `documents`-tagged records. +//! - **ExportBroker**: Files export declarations and records clearance decisions at the origin. Writes only +//! `export`-tagged records. +//! - **ImportBroker**: Handles duty assessment and import clearance at the destination. Writes only `import`-tagged +//! records. +//! - **Inspector**: Records the outcome of a customs physical inspection. Writes only `inspection`-tagged records; the +//! role is created mid-process when an inspection is triggered. +//! - **Supervisor**: Updates the mutable trail metadata (processing status). No record-write permissions. //! - **LockingAdmin**: Freezes the trail once the shipment is fully cleared. //! //! ## How the trail is used @@ -84,9 +81,30 @@ async fn main() -> Result<()> { // === Set up roles and capabilities for each actor === - issue_tagged_record_role(&admin, trail_id, "DocsOperator", "documents", docs_operator.sender_address()).await?; - issue_tagged_record_role(&admin, trail_id, "ExportBroker", "export", export_broker.sender_address()).await?; - issue_tagged_record_role(&admin, trail_id, "ImportBroker", "import", import_broker.sender_address()).await?; + issue_tagged_record_role( + &admin, + trail_id, + "DocsOperator", + "documents", + docs_operator.sender_address(), + ) + .await?; + issue_tagged_record_role( + &admin, + trail_id, + "ExportBroker", + "export", + export_broker.sender_address(), + ) + .await?; + issue_tagged_record_role( + &admin, + trail_id, + "ImportBroker", + "import", + import_broker.sender_address(), + ) + .await?; admin .trail(trail_id) diff --git a/examples/audit-trail/real-world/02_clinical_trial.rs b/examples/audit-trail/real-world/02_clinical_trial.rs index 54d55bae..ec5ca145 100644 --- a/examples/audit-trail/real-world/02_clinical_trial.rs +++ b/examples/audit-trail/real-world/02_clinical_trial.rs @@ -12,13 +12,13 @@ //! - **Enroller**: Writes enrollment events. Restricted to the `enrollment` tag. //! - **SafetyOfficer**: Records adverse events and safety observations. Restricted to `safety`. //! - **EfficacyReviewer**: Records treatment outcomes. Restricted to `efficacy`. -//! - **PkAnalyst**: Records pharmacokinetic results. Restricted to the `pk` tag that is added -//! mid-study when a PK sub-study is initiated. -//! - **Monitor**: Updates the mutable study-phase metadata. Access is time-windowed to the -//! active study period (90 days from now). +//! - **PkAnalyst**: Records pharmacokinetic results. Restricted to the `pk` tag that is added mid-study when a PK +//! sub-study is initiated. +//! - **Monitor**: Updates the mutable study-phase metadata. Access is time-windowed to the active study period (90 days +//! from now). //! - **DataSafetyBoard**: Controls write and delete locks. Freezes the dataset after review. -//! - **Regulator**: Read-only verifier. In production this would use `AuditTrailClientReadOnly` -//! (no signing key); here a funded client is used to keep the example self-contained. +//! - **Regulator**: Read-only verifier. In production this would use `AuditTrailClientReadOnly` (no signing key); here +//! a funded client is used to keep the example self-contained. //! //! ## How the trail is used //! @@ -27,8 +27,8 @@ //! - record tags: `enrollment`, `safety`, `efficacy`, `pk` (added mid-study) //! - roles and capabilities: each role writes only its designated tag //! - time-constrained capabilities: Monitor access is windowed to the study period -//! - locking: a deletion window protects recent records; a time-lock freezes the dataset after -//! the Data Safety Board completes its review +//! - locking: a deletion window protects recent records; a time-lock freezes the dataset after the Data Safety Board +//! completes its review //! - read-only verification: a regulator inspects the trail without write access use anyhow::{Result, ensure}; @@ -92,7 +92,14 @@ async fn main() -> Result<()> { println!("Defining study roles..."); issue_tagged_record_role(&admin, trail_id, "Enroller", "enrollment", enroller.sender_address()).await?; - issue_tagged_record_role(&admin, trail_id, "SafetyOfficer", "safety", safety_officer.sender_address()).await?; + issue_tagged_record_role( + &admin, + trail_id, + "SafetyOfficer", + "safety", + safety_officer.sender_address(), + ) + .await?; issue_tagged_record_role( &admin, trail_id, @@ -209,12 +216,7 @@ async fn main() -> Result<()> { println!("--- Mid-Study Amendment ---"); // Admin adds the new tag and creates a role for the PK analyst. - admin - .trail(trail_id) - .tags() - .add("pk") - .build_and_execute(&admin) - .await?; + admin.trail(trail_id).tags().add("pk").build_and_execute(&admin).await?; println!("Added tag 'pk' (pharmacokinetics) to the trail."); issue_tagged_record_role(&admin, trail_id, "PkAnalyst", "pk", pk_analyst.sender_address()).await?; From c5331930e2e968a35b207a4fa45036a44499778e Mon Sep 17 00:00:00 2001 From: Yasir Date: Fri, 10 Apr 2026 16:44:06 +0300 Subject: [PATCH 155/189] fix: use cfg-gated now_ms() helper for WASM-compatible timestamp in capability lookup --- audit-trail-rs/Cargo.toml | 1 + .../src/core/internal/capability.rs | 27 ++++++++++++++----- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/audit-trail-rs/Cargo.toml b/audit-trail-rs/Cargo.toml index eb997a0e..648036ff 100644 --- a/audit-trail-rs/Cargo.toml +++ b/audit-trail-rs/Cargo.toml @@ -32,6 +32,7 @@ tokio = { workspace = true } [target.'cfg(target_arch = "wasm32")'.dependencies] iota_interaction_ts.workspace = true +js-sys = "0.3" product_common = { workspace = true, default-features = false, features = ["bindings"] } tokio = { version = "1.46.1", default-features = false, features = ["sync"] } diff --git a/audit-trail-rs/src/core/internal/capability.rs b/audit-trail-rs/src/core/internal/capability.rs index 05f5c41d..9b721e34 100644 --- a/audit-trail-rs/src/core/internal/capability.rs +++ b/audit-trail-rs/src/core/internal/capability.rs @@ -2,7 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 use std::collections::HashSet; -use std::time::{SystemTime, UNIX_EPOCH}; use iota_interaction::move_types::language_storage::StructTag; use iota_interaction::rpc_types::{ @@ -81,10 +80,7 @@ where P: Fn(&Capability) -> bool + Send, { let revoked_capability_ids = revoked_capability_ids(client, trail).await?; - let now_ms = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as u64; + let now_ms = now_ms(); let tf_components_package_id = client .tf_components_package_id() .expect("TfComponents package ID should be present for audit trail clients"); @@ -208,7 +204,7 @@ pub(crate) async fn find_capable_cap_for_tag( trail_id: ObjectID, trail: &OnChainAuditTrail, tag: &str, -) -> Result +) -> Result where C: CoreClientReadOnly + OptionalSync, { @@ -238,6 +234,25 @@ where tx::get_object_ref_by_id(client, &object_id).await } +/// Returns the current wall-clock time as milliseconds since the Unix epoch. +/// +/// Uses `std::time::SystemTime` on native targets and `js_sys::Date::now()` on +/// `wasm32`, where `SystemTime` is not available. +pub(crate) fn now_ms() -> u64 { + #[cfg(not(target_arch = "wasm32"))] + { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 + } + #[cfg(target_arch = "wasm32")] + { + js_sys::Date::now() as u64 + } +} + #[cfg(test)] mod tests { use std::collections::HashSet; From 4f4c940af9725c285585b1c77f3b2a7fa369be96 Mon Sep 17 00:00:00 2001 From: Yasir Date: Fri, 10 Apr 2026 16:54:40 +0300 Subject: [PATCH 156/189] refactor: inline accessor variables ALAP in 05_manage_access examples --- .../examples/src/05_manage_access.ts | 47 +++++++++++++------ examples/audit-trail/05_manage_access.rs | 44 ++++++++++++----- 2 files changed, 64 insertions(+), 27 deletions(-) diff --git a/bindings/wasm/audit_trail_wasm/examples/src/05_manage_access.ts b/bindings/wasm/audit_trail_wasm/examples/src/05_manage_access.ts index 9b1a98c1..109ed8cb 100644 --- a/bindings/wasm/audit_trail_wasm/examples/src/05_manage_access.ts +++ b/bindings/wasm/audit_trail_wasm/examples/src/05_manage_access.ts @@ -30,11 +30,12 @@ export async function manageAccess(): Promise { const { output: trail } = await createTrailWithSeedRecord(admin); const trailId = trail.id; - const trailHandle = admin.trail(trailId); - const role = trailHandle.access().forRole("Operations"); // 1. Create the role - const createdRole = await role + const createdRole = await admin + .trail(trailId) + .access() + .forRole("Operations") .create(PermissionSet.recordAdminPermissions()) .withGasBudget(TEST_GAS_BUDGET) .buildAndExecute(admin); @@ -47,17 +48,21 @@ export async function manageAccess(): Promise { Permission.DeleteAllRecords, ]; const updatedPermissions = new PermissionSet(updatedPermissionValues); - const updatedRole = await role + const updatedRole = await admin + .trail(trailId) + .access() + .forRole("Operations") .updatePermissions(updatedPermissions) .withGasBudget(TEST_GAS_BUDGET) .buildAndExecute(admin); console.log("Updated role permissions:", updatedRole.output.permissions.permissions.map((p) => p.toString())); // 3. Issue a constrained capability bound to operationsUser's address. - const constrainedCap = await role - .issueCapability( - new CapabilityIssueOptions(operationsUser.senderAddress(), undefined, BigInt(4_102_444_800_000)), - ) + const constrainedCap = await admin + .trail(trailId) + .access() + .forRole("Operations") + .issueCapability(new CapabilityIssueOptions(operationsUser.senderAddress(), undefined, BigInt(4_102_444_800_000))) .withGasBudget(TEST_GAS_BUDGET) .buildAndExecute(admin); console.log("\nIssued constrained capability:"); @@ -66,7 +71,7 @@ export async function manageAccess(): Promise { console.log(" valid_until =", constrainedCap.output.validUntil, "\n"); // Verify the on-chain role matches the updated permissions. - const onChain = await trailHandle.get(); + const onChain = await admin.trail(trailId).get(); const opsRole = onChain.roles.roles.find((r) => r.name === "Operations"); assert.ok(opsRole, "Operations role must exist"); const opsPermSet = new Set(opsRole.permissions.map((p) => p.toString())); @@ -75,7 +80,8 @@ export async function manageAccess(): Promise { } // 4. Revoke the constrained capability. - await trailHandle + await admin + .trail(trailId) .access() .revokeCapability(constrainedCap.output.capabilityId, constrainedCap.output.validUntil) .withGasBudget(TEST_GAS_BUDGET) @@ -83,11 +89,15 @@ export async function manageAccess(): Promise { console.log("Revoked capability", constrainedCap.output.capabilityId, "\n"); // 5. Issue a disposable capability and destroy it. - const disposableCap = await role + const disposableCap = await admin + .trail(trailId) + .access() + .forRole("Operations") .issueCapability(new CapabilityIssueOptions(operationsUser.senderAddress())) .withGasBudget(TEST_GAS_BUDGET) .buildAndExecute(admin); - await trailHandle + await admin + .trail(trailId) .access() .destroyCapability(disposableCap.output.capabilityId) .withGasBudget(TEST_GAS_BUDGET) @@ -95,7 +105,8 @@ export async function manageAccess(): Promise { console.log("Destroyed capability", disposableCap.output.capabilityId, "\n"); // 6. Clean up the revoked-capability registry entry so the role can be removed. - await trailHandle + await admin + .trail(trailId) .access() .cleanupRevokedCapabilities() .withGasBudget(TEST_GAS_BUDGET) @@ -103,8 +114,14 @@ export async function manageAccess(): Promise { console.log("Cleaned up revoked capability registry entries.\n"); // 7. Delete the role. - await role.delete().withGasBudget(TEST_GAS_BUDGET).buildAndExecute(admin); - const afterDelete = await trailHandle.get(); + await admin + .trail(trailId) + .access() + .forRole("Operations") + .delete() + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(admin); + const afterDelete = await admin.trail(trailId).get(); const opsRoleAfterDelete = afterDelete.roles.roles.find((r) => r.name === "Operations"); assert.equal(opsRoleAfterDelete, undefined, "role should be removed from the trail"); diff --git a/examples/audit-trail/05_manage_access.rs b/examples/audit-trail/05_manage_access.rs index 3a6cfc5c..d993024e 100644 --- a/examples/audit-trail/05_manage_access.rs +++ b/examples/audit-trail/05_manage_access.rs @@ -41,10 +41,12 @@ async fn main() -> Result<()> { .await? .output; - let trail = admin.trail(created.trail_id); - let role = trail.access().for_role("Operations"); + let trail_id = created.trail_id; - let created_role = role + let created_role = admin + .trail(trail_id) + .access() + .for_role("Operations") .create(PermissionSet::record_admin_permissions(), None) .build_and_execute(&admin) .await? @@ -59,14 +61,20 @@ async fn main() -> Result<()> { ]), }; - let updated_role = role + let updated_role = admin + .trail(trail_id) + .access() + .for_role("Operations") .update_permissions(updated_permissions.clone(), None) .build_and_execute(&admin) .await? .output; println!("Updated role permissions: {:?}\n", updated_role.permissions.permissions); - let constrained_capability = role + let constrained_capability = admin + .trail(trail_id) + .access() + .for_role("Operations") .issue_capability(CapabilityIssueOptions { issued_to: Some(operations_user.sender_address()), valid_from_ms: None, @@ -81,18 +89,22 @@ async fn main() -> Result<()> { constrained_capability.capability_id, constrained_capability.issued_to, constrained_capability.valid_until ); - let on_chain = trail.get().await?; + let on_chain = admin.trail(trail_id).get().await?; let role_definition = on_chain.roles.roles.get("Operations").expect("role must exist"); ensure!(role_definition.permissions == updated_permissions.permissions); - trail + admin + .trail(trail_id) .access() .revoke_capability(constrained_capability.capability_id, constrained_capability.valid_until) .build_and_execute(&admin) .await?; println!("Revoked capability {}\n", constrained_capability.capability_id); - let disposable_capability = role + let disposable_capability = admin + .trail(trail_id) + .access() + .for_role("Operations") .issue_capability(CapabilityIssueOptions { issued_to: Some(operations_user.sender_address()), valid_from_ms: None, @@ -102,23 +114,31 @@ async fn main() -> Result<()> { .await? .output; - trail + admin + .trail(trail_id) .access() .destroy_capability(disposable_capability.capability_id) .build_and_execute(&admin) .await?; println!("Destroyed capability {}\n", disposable_capability.capability_id); - trail + admin + .trail(trail_id) .access() .cleanup_revoked_capabilities() .build_and_execute(&admin) .await?; println!("Cleaned up revoked capability registry entries.\n"); - role.delete().build_and_execute(&admin).await?; + admin + .trail(trail_id) + .access() + .for_role("Operations") + .delete() + .build_and_execute(&admin) + .await?; - let after_delete = trail.get().await?; + let after_delete = admin.trail(trail_id).get().await?; ensure!( !after_delete.roles.roles.contains_key("Operations"), "role should be removed from the trail" From fe5a94e875673863c3ebf84125ad11a8d50a0def Mon Sep 17 00:00:00 2001 From: Yasir Date: Fri, 10 Apr 2026 17:13:10 +0300 Subject: [PATCH 157/189] fix: issue disposable capability to admin for destroyCapability demo --- .../wasm/audit_trail_wasm/examples/src/05_manage_access.ts | 6 ++++-- examples/audit-trail/05_manage_access.rs | 4 +++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/bindings/wasm/audit_trail_wasm/examples/src/05_manage_access.ts b/bindings/wasm/audit_trail_wasm/examples/src/05_manage_access.ts index 109ed8cb..d080779f 100644 --- a/bindings/wasm/audit_trail_wasm/examples/src/05_manage_access.ts +++ b/bindings/wasm/audit_trail_wasm/examples/src/05_manage_access.ts @@ -88,12 +88,14 @@ export async function manageAccess(): Promise { .buildAndExecute(admin); console.log("Revoked capability", constrainedCap.output.capabilityId, "\n"); - // 5. Issue a disposable capability and destroy it. + // 5. Issue a disposable capability (to admin) and destroy it. + // destroyCapability consumes the capability object, so the signer must own it. + // The capability is issued to admin so admin can destroy it directly. const disposableCap = await admin .trail(trailId) .access() .forRole("Operations") - .issueCapability(new CapabilityIssueOptions(operationsUser.senderAddress())) + .issueCapability(new CapabilityIssueOptions(admin.senderAddress())) .withGasBudget(TEST_GAS_BUDGET) .buildAndExecute(admin); await admin diff --git a/examples/audit-trail/05_manage_access.rs b/examples/audit-trail/05_manage_access.rs index d993024e..828db2d4 100644 --- a/examples/audit-trail/05_manage_access.rs +++ b/examples/audit-trail/05_manage_access.rs @@ -101,12 +101,14 @@ async fn main() -> Result<()> { .await?; println!("Revoked capability {}\n", constrained_capability.capability_id); + // destroy_capability consumes the capability object, so the signer must own it. + // The capability is issued to admin so admin can destroy it directly. let disposable_capability = admin .trail(trail_id) .access() .for_role("Operations") .issue_capability(CapabilityIssueOptions { - issued_to: Some(operations_user.sender_address()), + issued_to: Some(admin.sender_address()), valid_from_ms: None, valid_until_ms: None, }) From 24c31a23a88415f328c8b47ad3d72aead7c7625d Mon Sep 17 00:00:00 2001 From: Yasir Date: Fri, 10 Apr 2026 17:18:38 +0300 Subject: [PATCH 158/189] chore: fmt &type fix --- .../wasm/audit_trail_wasm/examples/src/05_manage_access.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/bindings/wasm/audit_trail_wasm/examples/src/05_manage_access.ts b/bindings/wasm/audit_trail_wasm/examples/src/05_manage_access.ts index d080779f..3fd1a0f0 100644 --- a/bindings/wasm/audit_trail_wasm/examples/src/05_manage_access.ts +++ b/bindings/wasm/audit_trail_wasm/examples/src/05_manage_access.ts @@ -62,7 +62,9 @@ export async function manageAccess(): Promise { .trail(trailId) .access() .forRole("Operations") - .issueCapability(new CapabilityIssueOptions(operationsUser.senderAddress(), undefined, BigInt(4_102_444_800_000))) + .issueCapability( + new CapabilityIssueOptions(operationsUser.senderAddress(), undefined, BigInt(4_102_444_800_000)), + ) .withGasBudget(TEST_GAS_BUDGET) .buildAndExecute(admin); console.log("\nIssued constrained capability:"); @@ -74,7 +76,7 @@ export async function manageAccess(): Promise { const onChain = await admin.trail(trailId).get(); const opsRole = onChain.roles.roles.find((r) => r.name === "Operations"); assert.ok(opsRole, "Operations role must exist"); - const opsPermSet = new Set(opsRole.permissions.map((p) => p.toString())); + const opsPermSet = new Set(opsRole?.permissions.map((p) => p.toString())); for (const perm of updatedPermissionValues) { assert(opsPermSet.has(perm.toString()), `role should contain ${perm}`); } From 3a64d0ea36ea4125b1e49af033f013bb8a9f6afa Mon Sep 17 00:00:00 2001 From: Yasir Date: Fri, 10 Apr 2026 21:18:25 +0300 Subject: [PATCH 159/189] fix: use Web Crypto API for SHA-256 in customs clearance example Node's crypto.createHash is not available in the browser. The Web Crypto API (crypto.subtle.digest) works in both environments. --- .../examples/src/real-world/01_customs_clearance.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 09c097ee..85e6ccbf 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 @@ -41,7 +41,6 @@ import { TimeLock, } from "@iota/audit-trail/node"; import { strict as assert } from "assert"; -import { createHash } from "crypto"; import { getFundedClient, TEST_GAS_BUDGET } from "../util"; export async function customsClearance(): Promise { @@ -119,7 +118,8 @@ export async function customsClearance(): Promise { // Documents are stored off-chain in an access-controlled environment (e.g. a TWIN node). // Only the SHA-256 fingerprint is committed on-chain for tamper-evidence. - const invoiceHash = createHash("sha256").update("invoice-SHP-2026-CLEAR-001-v1.pdf").digest(); + const invoiceBytes = new TextEncoder().encode("invoice-SHP-2026-CLEAR-001-v1.pdf"); + const invoiceHash = new Uint8Array(await crypto.subtle.digest("SHA-256", invoiceBytes)); const docsUploaded = await docsOperator .trail(trailId) .records() From 07fcbf6916abbf6b3f9c744b78a2ac3babec5aa5 Mon Sep 17 00:00:00 2001 From: Yasir Date: Mon, 13 Apr 2026 14:13:55 +0300 Subject: [PATCH 160/189] Fix post-merge audit trail test fallout --- .../src/core/access/transactions.rs | 7 +-- .../src/core/internal/capability.rs | 22 +++++---- audit-trail-rs/tests/e2e/records.rs | 49 ------------------- examples/utils/utils.rs | 4 +- 4 files changed, 15 insertions(+), 67 deletions(-) diff --git a/audit-trail-rs/src/core/access/transactions.rs b/audit-trail-rs/src/core/access/transactions.rs index 82cc431a..7380dd4f 100644 --- a/audit-trail-rs/src/core/access/transactions.rs +++ b/audit-trail-rs/src/core/access/transactions.rs @@ -224,12 +224,7 @@ pub struct DeleteRole { impl DeleteRole { /// Creates a `DeleteRole` transaction builder payload. - pub fn new( - trail_id: ObjectID, - owner: IotaAddress, - name: String, - selected_capability_id: Option, - ) -> Self { + pub fn new(trail_id: ObjectID, owner: IotaAddress, name: String, selected_capability_id: Option) -> Self { Self { trail_id, owner, diff --git a/audit-trail-rs/src/core/internal/capability.rs b/audit-trail-rs/src/core/internal/capability.rs index ce1bdfb2..eeb1ca33 100644 --- a/audit-trail-rs/src/core/internal/capability.rs +++ b/audit-trail-rs/src/core/internal/capability.rs @@ -287,8 +287,7 @@ mod tests { let owner = IotaAddress::random_for_testing_only(); let trail_id = dbg_object_id(6); let valid_roles = HashSet::from(["Writer".to_string()]); - let mut cap = make_capability(dbg_object_id(7), trail_id, "Writer", None); - cap.valid_from = Some(2_000); + let cap = make_capability(dbg_object_id(7), trail_id, "Writer", None, Some(2_000), None); assert!(!capability_matches(&cap, owner, 1_999, &HashSet::new(), &|candidate| { candidate.matches_target_and_role(trail_id, &valid_roles) @@ -303,8 +302,7 @@ mod tests { let owner = IotaAddress::random_for_testing_only(); let trail_id = dbg_object_id(8); let valid_roles = HashSet::from(["Writer".to_string()]); - let mut cap = make_capability(dbg_object_id(9), trail_id, "Writer", None); - cap.valid_until = Some(2_000); + let cap = make_capability(dbg_object_id(9), trail_id, "Writer", None, None, Some(2_000)); assert!(capability_matches(&cap, owner, 2_000, &HashSet::new(), &|candidate| { candidate.matches_target_and_role(trail_id, &valid_roles) @@ -321,7 +319,7 @@ mod tests { let valid_roles = HashSet::from(["Writer".to_string()]); let cap = make_capability(dbg_object_id(7), trail_id, "Writer", None, None, None); - assert!(capability_matches(&cap, owner, &HashSet::new(), &|candidate| { + assert!(capability_matches(&cap, owner, 0, &HashSet::new(), &|candidate| { candidate.matches_target_and_role(trail_id, &valid_roles) })); } @@ -333,13 +331,13 @@ mod tests { let valid_roles = HashSet::from(["Writer".to_string()]); let cap = make_capability(dbg_object_id(9), trail_id, "Reader", None, None, None); - assert!(!capability_matches(&cap, owner, &HashSet::new(), &|candidate| { + assert!(!capability_matches(&cap, owner, 0, &HashSet::new(), &|candidate| { candidate.matches_target_and_role(trail_id, &valid_roles) })); } #[test] - fn capability_matches_leaves_time_constraints_to_on_chain_validation() { + fn capability_matches_honors_time_constraints() { let owner = IotaAddress::random_for_testing_only(); let trail_id = dbg_object_id(10); let valid_roles = HashSet::from(["Writer".to_string()]); @@ -352,9 +350,13 @@ mod tests { Some(1_700_000_005_000), ); - assert!(capability_matches(&cap, owner, &HashSet::new(), &|candidate| { - candidate.matches_target_and_role(trail_id, &valid_roles) - })); + assert!(capability_matches( + &cap, + owner, + 1_700_000_000_000, + &HashSet::new(), + &|candidate| { candidate.matches_target_and_role(trail_id, &valid_roles) } + )); } fn make_capability( diff --git a/audit-trail-rs/tests/e2e/records.rs b/audit-trail-rs/tests/e2e/records.rs index e26524b5..ea798b9c 100644 --- a/audit-trail-rs/tests/e2e/records.rs +++ b/audit-trail-rs/tests/e2e/records.rs @@ -288,55 +288,6 @@ async fn revoked_capability_cannot_add_record_without_fallback() -> anyhow::Resu Ok(()) } -#[tokio::test] - // Tagged record flow. - let tagged_trail_id = client - .create_test_trail_with_tags(Data::text("records-revoked-tagged"), ["finance"]) - .await?; - let tagged_records = client.trail(tagged_trail_id).records(); - let tagged_role_name = "TaggedWriter"; - - client - .create_role( - tagged_trail_id, - tagged_role_name, - [Permission::AddRecord], - Some(RoleTags::new(["finance"])), - ) - .await?; - - // Revoked capability. - let revoked_tagged_cap = client - .issue_cap(tagged_trail_id, tagged_role_name, CapabilityIssueOptions::default()) - .await?; - client - .trail(tagged_trail_id) - .access() - .revoke_capability(revoked_tagged_cap.capability_id, revoked_tagged_cap.valid_until) - .build_and_execute(&client) - .await?; - - // Valid fallback capability. - client - .issue_cap(tagged_trail_id, tagged_role_name, CapabilityIssueOptions::default()) - .await?; - - let tagged_added = tagged_records - .add( - Data::text("finance entry"), - Some("tagged".to_string()), - Some("finance".to_string()), - ) - .build_and_execute(&client) - .await? - .output; - - assert_eq!(tagged_added.sequence_number, 1); - assert_eq!(tagged_records.get(1).await?.tag, Some("finance".to_string())); - - Ok(()) -} - #[tokio::test] async fn add_tagged_record_skips_revoked_capability_when_valid_one_exists() -> anyhow::Result<()> { let client = get_funded_test_client().await?; diff --git a/examples/utils/utils.rs b/examples/utils/utils.rs index 28ea0d9b..a3af6aec 100644 --- a/examples/utils/utils.rs +++ b/examples/utils/utils.rs @@ -62,8 +62,8 @@ pub async fn get_funded_audit_trail_client() -> Result Date: Tue, 14 Apr 2026 12:46:51 +0300 Subject: [PATCH 161/189] Remove unnecessary store ability from AuditTrail struct AuditTrail is only ever used as a top-level shared object via transfer::share_object. It is never wrapped in another struct or stored as a dynamic field, so the store ability serves no purpose and unnecessarily expands the public API surface of the type. --- audit-trail-move/sources/audit_trail.move | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/audit-trail-move/sources/audit_trail.move b/audit-trail-move/sources/audit_trail.move index 46d1132f..511e2b86 100644 --- a/audit-trail-move/sources/audit_trail.move +++ b/audit-trail-move/sources/audit_trail.move @@ -70,7 +70,7 @@ public struct ImmutableMetadata has copy, drop, store { /// It maintains an ordered sequence of records, each assigned a unique /// auto-incrementing sequence number. /// Uses capability-based RBAC to manage access to the trail and its records. -public struct AuditTrail has key, store { +public struct AuditTrail has key { id: UID, /// Address that created this trail creator: address, From aed0de79daed30a7ed01568550d6a0086205cb68 Mon Sep 17 00:00:00 2001 From: Yasir Date: Mon, 20 Apr 2026 12:25:10 +0300 Subject: [PATCH 162/189] chore: remove duplicate keyword --- audit-trail-rs/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/audit-trail-rs/Cargo.toml b/audit-trail-rs/Cargo.toml index 648036ff..b4c050d4 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 From e5a47b5f8186e284677eb303f22bc14e4044f02a Mon Sep 17 00:00:00 2001 From: Yasir Date: Mon, 20 Apr 2026 13:35:24 +0300 Subject: [PATCH 163/189] chore: add DPP examples --- audit-trail-move/Move.lock | 8 +- .../wasm/audit_trail_wasm/examples/README.md | 1 + .../audit_trail_wasm/examples/src/main.ts | 3 + .../src/real-world/01_customs_clearance.ts | 27 +- .../src/real-world/02_clinical_trial.ts | 27 +- .../real-world/03_digital_product_passport.ts | 340 ++++++++++++++ .../audit_trail_wasm/examples/src/tests.ts | 4 + .../audit_trail_wasm/examples/src/util.ts | 24 + .../audit_trail_wasm/examples/src/web-main.ts | 3 + examples/Cargo.toml | 4 + examples/audit-trail/README.md | 1 + .../real-world/01_customs_clearance.rs | 37 +- .../real-world/02_clinical_trial.rs | 37 +- .../real-world/03_digital_product_passport.rs | 440 ++++++++++++++++++ examples/audit-trail/run.sh | 1 + examples/utils/utils.rs | 35 +- notarization-move/Move.history.json | 10 +- 17 files changed, 870 insertions(+), 132 deletions(-) create mode 100644 bindings/wasm/audit_trail_wasm/examples/src/real-world/03_digital_product_passport.ts create mode 100644 examples/audit-trail/real-world/03_digital_product_passport.rs diff --git a/audit-trail-move/Move.lock b/audit-trail-move/Move.lock index b36d8ec9..800342fc 100644 --- a/audit-trail-move/Move.lock +++ b/audit-trail-move/Move.lock @@ -54,16 +54,16 @@ dependencies = [ ] [move.toolchain-version] -compiler-version = "1.20.0-rc" +compiler-version = "1.21.1-rc" edition = "2024.beta" flavor = "iota" [env] [env.localnet] -chain-id = "417321d4" -original-published-id = "0x70e6f4f0ba0d3fae15288bf254b34829ad3c122b7f73766b13233af6acaeb715" -latest-published-id = "0x70e6f4f0ba0d3fae15288bf254b34829ad3c122b7f73766b13233af6acaeb715" +chain-id = "d652bb15" +original-published-id = "0xb716f9c7b7fe6a14ac63040269bcc82fbc3ba3be589c32777bc0b45f790f11ed" +latest-published-id = "0xb716f9c7b7fe6a14ac63040269bcc82fbc3ba3be589c32777bc0b45f790f11ed" published-version = "1" [env.testnet] diff --git a/bindings/wasm/audit_trail_wasm/examples/README.md b/bindings/wasm/audit_trail_wasm/examples/README.md index 65aec6c9..8ca9670a 100644 --- a/bindings/wasm/audit_trail_wasm/examples/README.md +++ b/bindings/wasm/audit_trail_wasm/examples/README.md @@ -69,3 +69,4 @@ Available examples: | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | | `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 db36f304..3ec08c8e 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 85e6ccbf..c6286734 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 597602a7..a4deea4b 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 00000000..75498f88 --- /dev/null +++ b/bindings/wasm/audit_trail_wasm/examples/src/real-world/03_digital_product_passport.ts @@ -0,0 +1,340 @@ +// 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 7848a8ef..b7ba809a 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 96fc9415..524bcfc3 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 1555454d..8f82711e 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 5c0b68cf..a0354e00 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 23a0be5a..a2b4cd5d 100644 --- a/examples/audit-trail/README.md +++ b/examples/audit-trail/README.md @@ -81,6 +81,7 @@ IOTA_AUDIT_TRAIL_PKG_ID=0x... IOTA_TF_COMPONENTS_PKG_ID=0x... cargo run --releas | :----------------------------------------------------------------------------------------------------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [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 6c651fa9..4e720a50 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 ec5ca145..0a427f5f 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 00000000..57da7336 --- /dev/null +++ b/examples/audit-trail/real-world/03_digital_product_passport.rs @@ -0,0 +1,440 @@ +// 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 d6c68a03..8cf9c6cb 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 a3af6aec..3f0376de 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(()) +} diff --git a/notarization-move/Move.history.json b/notarization-move/Move.history.json index 30d7c448..fd070c33 100644 --- a/notarization-move/Move.history.json +++ b/notarization-move/Move.history.json @@ -1,18 +1,18 @@ { "aliases": { - "devnet": "daf90477", "testnet": "2304aa97", + "devnet": "daf90477", "mainnet": "6364aad5" }, "envs": { - "daf90477": [ - "0x72c8433b88e6bdee0eb02a257fdebd0ec2b6c990043f35b155cb4c5cf727fdca" + "6364aad5": [ + "0x909ce9dcd9a5e97b7b8884fac8e018fad9dece348bf73837379b8694ff684cf3" ], "2304aa97": [ "0x00412bd469b7f980227c6c574090348239852e43aa07818b315854fdd8a2d25f" ], - "6364aad5": [ - "0x909ce9dcd9a5e97b7b8884fac8e018fad9dece348bf73837379b8694ff684cf3" + "daf90477": [ + "0x72c8433b88e6bdee0eb02a257fdebd0ec2b6c990043f35b155cb4c5cf727fdca" ] } } \ No newline at end of file From c7686e4c54bad886e3b95bfb4ae3988d90233be8 Mon Sep 17 00:00:00 2001 From: Yasir Date: Mon, 20 Apr 2026 13:39:19 +0300 Subject: [PATCH 164/189] chore: drop move metadata drift --- audit-trail-move/Move.lock | 8 ++++---- notarization-move/Move.history.json | 10 +++++----- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/audit-trail-move/Move.lock b/audit-trail-move/Move.lock index 800342fc..b36d8ec9 100644 --- a/audit-trail-move/Move.lock +++ b/audit-trail-move/Move.lock @@ -54,16 +54,16 @@ dependencies = [ ] [move.toolchain-version] -compiler-version = "1.21.1-rc" +compiler-version = "1.20.0-rc" edition = "2024.beta" flavor = "iota" [env] [env.localnet] -chain-id = "d652bb15" -original-published-id = "0xb716f9c7b7fe6a14ac63040269bcc82fbc3ba3be589c32777bc0b45f790f11ed" -latest-published-id = "0xb716f9c7b7fe6a14ac63040269bcc82fbc3ba3be589c32777bc0b45f790f11ed" +chain-id = "417321d4" +original-published-id = "0x70e6f4f0ba0d3fae15288bf254b34829ad3c122b7f73766b13233af6acaeb715" +latest-published-id = "0x70e6f4f0ba0d3fae15288bf254b34829ad3c122b7f73766b13233af6acaeb715" published-version = "1" [env.testnet] diff --git a/notarization-move/Move.history.json b/notarization-move/Move.history.json index fd070c33..30d7c448 100644 --- a/notarization-move/Move.history.json +++ b/notarization-move/Move.history.json @@ -1,18 +1,18 @@ { "aliases": { - "testnet": "2304aa97", "devnet": "daf90477", + "testnet": "2304aa97", "mainnet": "6364aad5" }, "envs": { - "6364aad5": [ - "0x909ce9dcd9a5e97b7b8884fac8e018fad9dece348bf73837379b8694ff684cf3" + "daf90477": [ + "0x72c8433b88e6bdee0eb02a257fdebd0ec2b6c990043f35b155cb4c5cf727fdca" ], "2304aa97": [ "0x00412bd469b7f980227c6c574090348239852e43aa07818b315854fdd8a2d25f" ], - "daf90477": [ - "0x72c8433b88e6bdee0eb02a257fdebd0ec2b6c990043f35b155cb4c5cf727fdca" + "6364aad5": [ + "0x909ce9dcd9a5e97b7b8884fac8e018fad9dece348bf73837379b8694ff684cf3" ] } } \ No newline at end of file From 4a36adffaec76fed2762980142facd3e253c2574 Mon Sep 17 00:00:00 2001 From: Yasir Date: Mon, 20 Apr 2026 14:13:55 +0300 Subject: [PATCH 165/189] chore: polish DPP example docs --- .../wasm/audit_trail_wasm/examples/README.md | 8 ++-- .../real-world/03_digital_product_passport.ts | 6 ++- examples/audit-trail/README.md | 8 ++-- .../real-world/03_digital_product_passport.rs | 37 +++++++------------ 4 files changed, 26 insertions(+), 33 deletions(-) diff --git a/bindings/wasm/audit_trail_wasm/examples/README.md b/bindings/wasm/audit_trail_wasm/examples/README.md index 8ca9670a..6f967994 100644 --- a/bindings/wasm/audit_trail_wasm/examples/README.md +++ b/bindings/wasm/audit_trail_wasm/examples/README.md @@ -65,8 +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/real-world/03_digital_product_passport.ts b/bindings/wasm/audit_trail_wasm/examples/src/real-world/03_digital_product_passport.ts index 75498f88..983e057a 100644 --- 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 @@ -264,7 +264,10 @@ export async function digitalProductPassport(): Promise { .withGasBudget(TEST_GAS_BUDGET) .buildAndExecute(epro); - console.log("EPRO added reward record", rewardEvent.output.sequenceNumber + " for the verified maintenance event.\n"); + console.log( + "EPRO added reward record", + rewardEvent.output.sequenceNumber + " for the verified maintenance event.\n", + ); await lifecycleManager .trail(trailId) @@ -316,7 +319,6 @@ export async function digitalProductPassport(): Promise { console.log("\nDigital Product Passport scenario completed successfully."); } - async function issueMetadataRole( admin: AuditTrailClient, trailId: string, diff --git a/examples/audit-trail/README.md b/examples/audit-trail/README.md index a2b4cd5d..840db4ef 100644 --- a/examples/audit-trail/README.md +++ b/examples/audit-trail/README.md @@ -77,10 +77,10 @@ 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/03_digital_product_passport.rs b/examples/audit-trail/real-world/03_digital_product_passport.rs index 57da7336..dadef6da 100644 --- a/examples/audit-trail/real-world/03_digital_product_passport.rs +++ b/examples/audit-trail/real-world/03_digital_product_passport.rs @@ -10,40 +10,31 @@ //! 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 +//! - 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. +//! - **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. +//! - **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 +//! - 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; From 142b9ab0e050ee33c163f8368e8aeb524351331c Mon Sep 17 00:00:00 2001 From: Yasir Date: Wed, 22 Apr 2026 12:41:16 +0300 Subject: [PATCH 166/189] chore: enhance audit trail API with return values for capabilities and records --- audit-trail-move/sources/audit_trail.move | 68 ++++++++++++++++--- audit-trail-move/tests/locking_tests.move | 10 +-- audit-trail-move/tests/record_tests.move | 5 +- .../src/core/access/transactions.rs | 29 ++++++-- .../src/core/records/transactions.rs | 10 +-- audit-trail-rs/src/core/types/event.rs | 15 ++++ audit-trail-rs/tests/e2e/access.rs | 11 ++- audit-trail-rs/tests/e2e/records.rs | 15 ++-- audit-trail-rs/tests/e2e/trail.rs | 2 +- bindings/wasm/audit_trail_wasm/src/trail.rs | 15 ++-- bindings/wasm/audit_trail_wasm/src/types.rs | 27 +++++++- examples/audit-trail/06_delete_records.rs | 4 +- examples/audit-trail/08_delete_audit_trail.rs | 2 +- 13 files changed, 174 insertions(+), 39 deletions(-) diff --git a/audit-trail-move/sources/audit_trail.move b/audit-trail-move/sources/audit_trail.move index 511e2b86..410b580c 100644 --- a/audit-trail-move/sources/audit_trail.move +++ b/audit-trail-move/sources/audit_trail.move @@ -23,7 +23,11 @@ use audit_trail::{ }; use iota::{clock::{Self, Clock}, event, linked_table::{Self, LinkedTable}, vec_set::VecSet}; use std::string::String; -use tf_components::{capability::Capability, role_map::{Self, RoleMap}, timelock::TimeLock}; +use tf_components::{ + capability::Capability, + role_map::{Self, RoleMap}, + timelock::TimeLock +}; // ===== Errors ===== #[error] @@ -125,6 +129,24 @@ public struct RecordDeleted has copy, drop { timestamp: u64, } +/// Emitted when expired revoked-capability entries are removed from the denylist +public struct RevokedCapabilitiesCleanedUp has copy, drop { + trail_id: ID, + cleaned_count: u64, + cleaned_by: address, + timestamp: u64, +} + +/// Returned when a capability is issued through the audit-trail API +public struct CapabilityIssuedReceipt has copy, drop { + target_key: ID, + capability_id: ID, + role: String, + issued_to: Option

, + valid_from: Option, + valid_until: Option, +} + // ===== Constructors ===== /// Create immutable trail metadata @@ -277,6 +299,7 @@ fun assert_record_tag_allowed( /// Add a record to the trail /// /// Records are added sequentially with auto-assigned sequence numbers. +/// Returns the same receipt that is emitted as the `RecordAdded` event. public fun add_record( self: &mut AuditTrail, cap: &Capability, @@ -285,7 +308,7 @@ public fun add_record( record_tag: Option, clock: &Clock, ctx: &mut TxContext, -) { +): RecordAdded { assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); self .roles @@ -320,12 +343,15 @@ public fun add_record( linked_table::push_back(&mut self.records, seq, record); self.sequence_number = self.sequence_number + 1; - event::emit(RecordAdded { + let output = RecordAdded { trail_id, sequence_number: seq, added_by: caller, timestamp, - }); + }; + + event::emit(copy output); + output } /// Delete a record from the trail by sequence number @@ -377,14 +403,14 @@ public fun delete_record( /// Delete up to `limit` records from the front of the trail. /// /// Requires `DeleteAllRecords` permission. This operation bypasses record locks. -/// Returns the number of records deleted in this batch. +/// Returns the sequence numbers deleted in this batch, in deletion order. public fun delete_records_batch( self: &mut AuditTrail, cap: &Capability, limit: u64, clock: &Clock, ctx: &mut TxContext, -): u64 { +): vector { assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); self .roles @@ -396,6 +422,7 @@ public fun delete_records_batch( ); let mut deleted = 0; + let mut deleted_sequence_numbers = vector::empty(); let caller = ctx.sender(); let timestamp = clock.timestamp_ms(); let trail_id = self.id(); @@ -424,11 +451,12 @@ public fun delete_records_batch( deleted_by: caller, timestamp, }); + vector::push_back(&mut deleted_sequence_numbers, sequence_number); deleted = deleted + 1; }; - deleted + deleted_sequence_numbers } /// Delete an empty audit trail. @@ -747,6 +775,7 @@ public fun delete_role( /// Issues a new capability for an existing role. /// /// The capability object is transferred to `issued_to` if provided, otherwise to the caller. +/// Returns the same receipt that is emitted as the `CapabilityIssued` event. public fun new_capability( self: &mut AuditTrail, cap: &Capability, @@ -756,7 +785,7 @@ public fun new_capability( valid_until: Option, clock: &Clock, ctx: &mut TxContext, -) { +): CapabilityIssuedReceipt { assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); let recipient = if (issued_to.is_some()) { @@ -776,7 +805,16 @@ public fun new_capability( clock, ctx, ); + let output = CapabilityIssuedReceipt { + target_key: self.id(), + capability_id: new_cap.id(), + role: *new_cap.role(), + issued_to: *new_cap.issued_to(), + valid_from: *new_cap.valid_from(), + valid_until: *new_cap.valid_until(), + }; transfer::public_transfer(new_cap, recipient); + output } /// Revokes an issued capability by ID. @@ -871,6 +909,8 @@ public fun revoke_initial_admin_capability( /// Entries with `valid_until == 0` (i.e. capabilities that had no expiry) are kept, /// since they remain potentially valid and must stay on the denylist. /// +/// Returns the same receipt that is emitted as the `RevokedCapabilitiesCleanedUp` event. +/// /// Parameters /// ---------- /// - cap: Reference to the capability used to authorize this operation. @@ -885,8 +925,9 @@ public fun cleanup_revoked_capabilities( cap: &Capability, clock: &Clock, ctx: &TxContext, -) { +): RevokedCapabilitiesCleanedUp { assert!(self.version == PACKAGE_VERSION, EPackageVersionMismatch); + let revoked_count_before = linked_table::length(role_map::revoked_capabilities(self.access())); self .access_mut() .cleanup_revoked_capabilities( @@ -894,6 +935,15 @@ public fun cleanup_revoked_capabilities( clock, ctx, ); + let revoked_count_after = linked_table::length(role_map::revoked_capabilities(self.access())); + let output = RevokedCapabilitiesCleanedUp { + trail_id: self.id(), + cleaned_count: revoked_count_before - revoked_count_after, + cleaned_by: ctx.sender(), + timestamp: clock::timestamp_ms(clock), + }; + event::emit(copy output); + output } // ===== Trail Query Functions ===== diff --git a/audit-trail-move/tests/locking_tests.move b/audit-trail-move/tests/locking_tests.move index 5ccb4e14..979c5e31 100644 --- a/audit-trail-move/tests/locking_tests.move +++ b/audit-trail-move/tests/locking_tests.move @@ -998,8 +998,9 @@ fun test_delete_records_batch_bypasses_record_lock() { &clock, ts::ctx(&mut scenario), ); - assert!(deleted == 1, 0); - assert!(trail.record_count() == 0, 1); + assert!(vector::length(&deleted) == 1, 0); + assert!(*vector::borrow(&deleted, 0) == 0, 1); + assert!(trail.record_count() == 0, 2); delete_all_cap.destroy_for_testing(); cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); @@ -1121,8 +1122,9 @@ fun test_delete_audit_trail_after_batch_cleanup() { &clock, ts::ctx(&mut scenario), ); - assert!(deleted == 1, 0); - assert!(trail.record_count() == 0, 1); + assert!(vector::length(&deleted) == 1, 0); + assert!(*vector::borrow(&deleted, 0) == 0, 1); + assert!(trail.record_count() == 0, 2); main::delete_audit_trail(trail, &delete_maintenance_cap, &clock, ts::ctx(&mut scenario)); diff --git a/audit-trail-move/tests/record_tests.move b/audit-trail-move/tests/record_tests.move index e3be34f2..570611cc 100644 --- a/audit-trail-move/tests/record_tests.move +++ b/audit-trail-move/tests/record_tests.move @@ -309,8 +309,9 @@ fun test_delete_records_batch_with_matching_role_tags() { ); let deleted = trail.delete_records_batch(&cap, 10, &clock, ts::ctx(&mut scenario)); - assert!(deleted == 1, 0); - assert!(trail.record_count() == 0, 1); + assert!(vector::length(&deleted) == 1, 0); + assert!(*vector::borrow(&deleted, 0) == 0, 1); + assert!(trail.record_count() == 0, 2); cleanup_capability_trail_and_clock(&scenario, cap, trail, clock); }; diff --git a/audit-trail-rs/src/core/access/transactions.rs b/audit-trail-rs/src/core/access/transactions.rs index 7380dd4f..3a09cbac 100644 --- a/audit-trail-rs/src/core/access/transactions.rs +++ b/audit-trail-rs/src/core/access/transactions.rs @@ -18,7 +18,8 @@ use tokio::sync::OnceCell; use super::operations::AccessOps; use crate::core::types::{ CapabilityDestroyed, CapabilityIssueOptions, CapabilityIssued, CapabilityRevoked, Event, PermissionSet, - RawRoleCreated, RawRoleDeleted, RawRoleUpdated, RoleCreated, RoleDeleted, RoleTags, RoleUpdated, + RawRoleCreated, RawRoleDeleted, RawRoleUpdated, RevokedCapabilitiesCleanedUp, RoleCreated, RoleDeleted, RoleTags, + RoleUpdated, }; use crate::error::Error; @@ -709,7 +710,7 @@ impl Transaction for RevokeInitialAdminCapability { /// Transaction that cleans up expired revoked-capability entries. /// /// This does not revoke additional capabilities. It only prunes denylist entries whose stored expiry has -/// already elapsed. +/// already elapsed and returns the typed cleanup receipt emitted by the Move package. #[derive(Debug, Clone)] pub struct CleanupRevokedCapabilities { trail_id: ObjectID, @@ -741,7 +742,7 @@ impl CleanupRevokedCapabilities { #[cfg_attr(feature = "send-sync", async_trait)] impl Transaction for CleanupRevokedCapabilities { type Error = Error; - type Output = (); + type Output = RevokedCapabilitiesCleanedUp; async fn build_programmable_transaction(&self, client: &C) -> Result where @@ -750,10 +751,30 @@ impl Transaction for CleanupRevokedCapabilities { self.cached_ptb.get_or_try_init(|| self.make_ptb(client)).await.cloned() } + async fn apply_with_events( + self, + _: &mut IotaTransactionBlockEffects, + events: &mut IotaTransactionBlockEvents, + _: &C, + ) -> Result + where + C: CoreClientReadOnly + OptionalSync, + { + let event = events + .data + .iter() + .find_map(|data| { + serde_json::from_value::>(data.parsed_json.clone()).ok() + }) + .ok_or_else(|| Error::UnexpectedApiResponse("RevokedCapabilitiesCleanedUp event not found".to_string()))?; + + Ok(event.data) + } + async fn apply(self, _: &mut IotaTransactionBlockEffects, _: &C) -> Result where C: CoreClientReadOnly + OptionalSync, { - Ok(()) + unreachable!("RevokedCapabilitiesCleanedUp output requires transaction events") } } diff --git a/audit-trail-rs/src/core/records/transactions.rs b/audit-trail-rs/src/core/records/transactions.rs index ab4c4a63..f25e2eec 100644 --- a/audit-trail-rs/src/core/records/transactions.rs +++ b/audit-trail-rs/src/core/records/transactions.rs @@ -213,8 +213,9 @@ impl Transaction for DeleteRecord { /// Transaction that deletes multiple records in a batch operation. /// -/// The Move entry point deletes records from the front of the trail up to `limit` and reports the number of -/// deleted records through the emitted `RecordDeleted` events. +/// The Move entry point deletes records from the front of the trail up to `limit` and returns the deleted +/// sequence numbers. The current Rust implementation mirrors that output by collecting the matching +/// `RecordDeleted` events in order. #[derive(Debug, Clone)] pub struct DeleteRecordsBatch { /// Trail object ID containing the records. @@ -259,7 +260,7 @@ impl DeleteRecordsBatch { #[cfg_attr(feature = "send-sync", async_trait)] impl Transaction for DeleteRecordsBatch { type Error = Error; - type Output = u64; + type Output = Vec; async fn build_programmable_transaction(&self, client: &C) -> Result where @@ -281,7 +282,8 @@ impl Transaction for DeleteRecordsBatch { .data .iter() .filter_map(|data| serde_json::from_value::>(data.parsed_json.clone()).ok()) - .count() as u64; + .map(|event| event.data.sequence_number) + .collect(); Ok(deleted) } diff --git a/audit-trail-rs/src/core/types/event.rs b/audit-trail-rs/src/core/types/event.rs index 988f43bf..18a7385d 100644 --- a/audit-trail-rs/src/core/types/event.rs +++ b/audit-trail-rs/src/core/types/event.rs @@ -120,6 +120,21 @@ pub struct CapabilityRevoked { pub valid_until: u64, } +/// Event emitted when expired revoked-capability denylist entries are removed. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RevokedCapabilitiesCleanedUp { + /// Trail object ID whose denylist was pruned. + pub trail_id: ObjectID, + /// Number of expired entries removed by this cleanup call. + #[serde(deserialize_with = "deserialize_number_from_string")] + pub cleaned_count: u64, + /// Address that triggered the cleanup. + pub cleaned_by: IotaAddress, + /// Millisecond event timestamp. + #[serde(deserialize_with = "deserialize_number_from_string")] + pub timestamp: u64, +} + /// Event emitted when a role is created. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct RoleCreated { diff --git a/audit-trail-rs/tests/e2e/access.rs b/audit-trail-rs/tests/e2e/access.rs index 94b4e2d0..4ad98172 100644 --- a/audit-trail-rs/tests/e2e/access.rs +++ b/audit-trail-rs/tests/e2e/access.rs @@ -568,7 +568,16 @@ async fn cleanup_revoked_capabilities_removes_expired_entries() -> anyhow::Resul let before_cleanup = trail.get().await?; assert_eq!(before_cleanup.roles.revoked_capabilities.size, 1); - access.cleanup_revoked_capabilities().build_and_execute(&client).await?; + let cleaned = access + .cleanup_revoked_capabilities() + .build_and_execute(&client) + .await? + .output; + + assert_eq!(cleaned.trail_id, trail_id); + assert_eq!(cleaned.cleaned_count, 1); + assert_eq!(cleaned.cleaned_by, client.sender_address()); + assert!(cleaned.timestamp > 0); let after_cleanup = trail.get().await?; assert_eq!(after_cleanup.roles.revoked_capabilities.size, 0); diff --git a/audit-trail-rs/tests/e2e/records.rs b/audit-trail-rs/tests/e2e/records.rs index ea798b9c..ec04f3bb 100644 --- a/audit-trail-rs/tests/e2e/records.rs +++ b/audit-trail-rs/tests/e2e/records.rs @@ -993,7 +993,11 @@ async fn delete_records_batch_respects_limit_and_deletes_oldest_first() -> anyho assert_eq!(records.record_count().await?, 3); let deleted_two = records.delete_records_batch(2).build_and_execute(&client).await?.output; - assert_eq!(deleted_two, 2, "batch delete should stop at the provided limit"); + assert_eq!( + deleted_two, + vec![0, 1], + "batch delete should return the deleted sequence numbers" + ); assert_eq!(records.record_count().await?, 1); assert!(records.get(0).await.is_err(), "oldest record should be removed first"); assert!( @@ -1007,7 +1011,7 @@ async fn delete_records_batch_respects_limit_and_deletes_oldest_first() -> anyho .build_and_execute(&client) .await? .output; - assert_eq!(deleted_last, 1, "remaining record should be deleted"); + assert_eq!(deleted_last, vec![2], "remaining record should be deleted"); assert_eq!(records.record_count().await?, 0); let deleted_empty = records @@ -1015,7 +1019,10 @@ async fn delete_records_batch_respects_limit_and_deletes_oldest_first() -> anyho .build_and_execute(&client) .await? .output; - assert_eq!(deleted_empty, 0, "deleting from an empty trail should return zero"); + assert!( + deleted_empty.is_empty(), + "deleting from an empty trail should return no sequence numbers" + ); Ok(()) } @@ -1133,7 +1140,7 @@ async fn delete_records_batch_with_matching_role_tag_access_succeeds() -> anyhow .build_and_execute(&client) .await? .output; - assert_eq!(deleted, 2); + assert_eq!(deleted, vec![0, 1]); assert_eq!(records.record_count().await?, 0); assert!(records.get(1).await.is_err()); diff --git a/audit-trail-rs/tests/e2e/trail.rs b/audit-trail-rs/tests/e2e/trail.rs index 4ade501b..5ad3af83 100644 --- a/audit-trail-rs/tests/e2e/trail.rs +++ b/audit-trail-rs/tests/e2e/trail.rs @@ -479,7 +479,7 @@ async fn delete_records_batch_then_delete_audit_trail_roundtrip() -> anyhow::Res .build_and_execute(&client) .await? .output; - assert_eq!(deleted, 1, "initial record should be deleted in batch"); + assert_eq!(deleted, vec![0], "initial record should be deleted in batch"); assert_eq!(trail.records().record_count().await?, 0); let deleted_trail = trail.delete_audit_trail().build_and_execute(&client).await?.output; diff --git a/bindings/wasm/audit_trail_wasm/src/trail.rs b/bindings/wasm/audit_trail_wasm/src/trail.rs index dc9c995d..4df45227 100644 --- a/bindings/wasm/audit_trail_wasm/src/trail.rs +++ b/bindings/wasm/audit_trail_wasm/src/trail.rs @@ -14,7 +14,7 @@ use audit_trail::core::tags::{AddRecordTag, RemoveRecordTag}; use audit_trail::core::trail::{DeleteAuditTrail, Migrate, UpdateMetadata}; use audit_trail::core::types::{ AuditTrailDeleted, CapabilityDestroyed, CapabilityIssued, CapabilityRevoked, OnChainAuditTrail, RecordAdded, - RecordDeleted, RoleCreated, RoleDeleted, RoleUpdated, + RecordDeleted, RevokedCapabilitiesCleanedUp, RoleCreated, RoleDeleted, RoleUpdated, }; use iota_interaction_ts::bindings::{WasmIotaTransactionBlockEffects, WasmIotaTransactionBlockEvents}; use iota_interaction_ts::core_client::WasmCoreClientReadOnly; @@ -26,8 +26,9 @@ use wasm_bindgen::prelude::*; use crate::builder::WasmAuditTrailBuilder; use crate::types::{ WasmAuditTrailDeleted, WasmCapabilityDestroyed, WasmCapabilityIssued, WasmCapabilityRevoked, WasmEmpty, - WasmImmutableMetadata, WasmLinkedTable, WasmLockingConfig, WasmRecordAdded, WasmRecordDeleted, WasmRecordTagEntry, - WasmRoleCreated, WasmRoleDeleted, WasmRoleMap, WasmRoleUpdated, + WasmImmutableMetadata, WasmLinkedTable, WasmLockingConfig, WasmRecordAdded, WasmRecordDeleted, + WasmRecordTagEntry, WasmRevokedCapabilitiesCleanedUp, WasmRoleCreated, WasmRoleDeleted, WasmRoleMap, + WasmRoleUpdated, }; /// Read-only view of an on-chain audit trail for wasm consumers. @@ -519,8 +520,10 @@ impl WasmCleanupRevokedCapabilities { wasm_effects: &WasmIotaTransactionBlockEffects, wasm_events: &WasmIotaTransactionBlockEvents, client: &WasmCoreClientReadOnly, - ) -> Result { - apply_with_events(self.0, wasm_effects, wasm_events, client).await + ) -> Result { + let cleaned: RevokedCapabilitiesCleanedUp = + apply_with_events(self.0, wasm_effects, wasm_events, client).await?; + Ok(cleaned.into()) } } @@ -587,7 +590,7 @@ impl WasmDeleteRecordsBatch { wasm_effects: &WasmIotaTransactionBlockEffects, wasm_events: &WasmIotaTransactionBlockEvents, client: &WasmCoreClientReadOnly, - ) -> Result { + ) -> Result> { apply_with_events(self.0, wasm_effects, wasm_events, client).await } } diff --git a/bindings/wasm/audit_trail_wasm/src/types.rs b/bindings/wasm/audit_trail_wasm/src/types.rs index 52cc9586..5e62ed1d 100644 --- a/bindings/wasm/audit_trail_wasm/src/types.rs +++ b/bindings/wasm/audit_trail_wasm/src/types.rs @@ -6,7 +6,8 @@ use std::collections::{HashMap, HashSet}; use audit_trail::core::types::{ AuditTrailCreated, AuditTrailDeleted, Capability, CapabilityAdminPermissions, CapabilityDestroyed, CapabilityIssueOptions, CapabilityIssued, CapabilityRevoked, Data, ImmutableMetadata, LockingConfig, LockingWindow, - PaginatedRecord, Permission, PermissionSet, Record, RecordAdded, RecordCorrection, RecordDeleted, Role, + PaginatedRecord, Permission, PermissionSet, Record, RecordAdded, RecordCorrection, RecordDeleted, + RevokedCapabilitiesCleanedUp, Role, RoleAdminPermissions, RoleCreated, RoleDeleted, RoleMap, RoleTags, RoleUpdated, TimeLock, }; use iota_interaction::types::base_types::ObjectID; @@ -712,6 +713,30 @@ impl From for WasmCapabilityRevoked { } } +/// Event payload emitted when expired revoked-capability entries are cleaned up. +#[wasm_bindgen(js_name = RevokedCapabilitiesCleanedUp, getter_with_clone, inspectable)] +#[derive(Clone)] +pub struct WasmRevokedCapabilitiesCleanedUp { + #[wasm_bindgen(js_name = trailId)] + pub trail_id: String, + #[wasm_bindgen(js_name = cleanedCount)] + pub cleaned_count: u64, + #[wasm_bindgen(js_name = cleanedBy)] + pub cleaned_by: WasmIotaAddress, + pub timestamp: u64, +} + +impl From for WasmRevokedCapabilitiesCleanedUp { + fn from(value: RevokedCapabilitiesCleanedUp) -> Self { + Self { + trail_id: value.trail_id.to_string(), + cleaned_count: value.cleaned_count, + cleaned_by: value.cleaned_by.to_string(), + timestamp: value.timestamp, + } + } +} + /// Event payload emitted when a role is created. #[wasm_bindgen(js_name = RoleCreated, getter_with_clone, inspectable)] #[derive(Clone)] diff --git a/examples/audit-trail/06_delete_records.rs b/examples/audit-trail/06_delete_records.rs index c92f409f..591aaef9 100644 --- a/examples/audit-trail/06_delete_records.rs +++ b/examples/audit-trail/06_delete_records.rs @@ -106,8 +106,8 @@ async fn main() -> Result<()> { .await? .output; - println!("Batch deleted the remaining {deleted_remaining} records."); - ensure!(deleted_remaining == 2); + println!("Batch deleted the remaining sequence numbers: {deleted_remaining:?}"); + ensure!(deleted_remaining == vec![1, 2]); ensure!(records.record_count().await? == 0); Ok(()) diff --git a/examples/audit-trail/08_delete_audit_trail.rs b/examples/audit-trail/08_delete_audit_trail.rs index 52e55e5a..841bd8c0 100644 --- a/examples/audit-trail/08_delete_audit_trail.rs +++ b/examples/audit-trail/08_delete_audit_trail.rs @@ -78,7 +78,7 @@ async fn main() -> Result<()> { .build_and_execute(&maintenance_admin) .await? .output; - println!("Deleted {deleted_records} record(s) before trail removal.\n"); + println!("Deleted record sequence numbers {deleted_records:?} before trail removal.\n"); ensure!(maintenance_trail.records().record_count().await? == 0); From 34190c62fcbd9273fef11844be7ae491eccfe0ad Mon Sep 17 00:00:00 2001 From: chrisgitiota Date: Wed, 22 Apr 2026 16:03:32 +0200 Subject: [PATCH 167/189] Optimize get_funded_audit_trail_client() code to be referenced in docs --- examples/utils/utils.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/examples/utils/utils.rs b/examples/utils/utils.rs index 3f0376de..aba78a64 100644 --- a/examples/utils/utils.rs +++ b/examples/utils/utils.rs @@ -56,10 +56,6 @@ pub async fn get_funded_audit_trail_client() -> Result Result Date: Tue, 28 Apr 2026 13:49:49 +0300 Subject: [PATCH 168/189] chore: clarify example actor terminology across audit trail docs and code Rename ambiguous actor labels like "Admin", "RecordAdmin", and "FinanceWriter" to explicit "client" variants (e.g., "Admin client", "Record admin client") in both Rust and TypeScript examples. Also fixes minor indentation in `examples/utils/utils.rs` and renames a destructured variable in the DPP example for clarity. --- .../examples/src/01_create_audit_trail.ts | 50 ++--- .../examples/src/02_add_and_read_records.ts | 61 +++-- .../examples/src/03_update_metadata.ts | 64 +++--- .../examples/src/04_configure_locking.ts | 93 ++++---- .../examples/src/05_manage_access.ts | 99 ++++---- .../examples/src/06_delete_records.ts | 74 +++--- .../src/07_access_read_only_methods.ts | 55 +++-- .../examples/src/08_delete_audit_trail.ts | 39 ++-- .../src/advanced/09_tagged_records.ts | 52 +++-- .../src/advanced/10_capability_constraints.ts | 65 +++--- .../src/advanced/11_manage_record_tags.ts | 76 ++++--- .../src/real-world/01_customs_clearance.ts | 62 ++--- .../src/real-world/02_clinical_trial.ts | 79 ++++--- .../real-world/03_digital_product_passport.ts | 52 ++--- .../audit_trail_wasm/examples/src/util.ts | 20 +- examples/audit-trail/01_create_audit_trail.rs | 65 +++--- .../audit-trail/02_add_and_read_records.rs | 53 +++-- examples/audit-trail/03_update_metadata.rs | 61 ++--- examples/audit-trail/04_configure_locking.rs | 104 ++++----- examples/audit-trail/05_manage_access.rs | 105 +++++---- examples/audit-trail/06_delete_records.rs | 76 ++++--- .../07_access_read_only_methods.rs | 70 +++--- examples/audit-trail/08_delete_audit_trail.rs | 47 ++-- .../audit-trail/advanced/09_tagged_records.rs | 56 ++--- .../advanced/10_capability_constraints.rs | 76 ++++--- .../advanced/11_manage_record_tags.rs | 95 ++++---- .../real-world/01_customs_clearance.rs | 157 +++++++------ .../real-world/02_clinical_trial.rs | 198 +++++++++------- .../real-world/03_digital_product_passport.rs | 212 ++++++++++-------- examples/utils/utils.rs | 4 +- 30 files changed, 1234 insertions(+), 1086 deletions(-) diff --git a/bindings/wasm/audit_trail_wasm/examples/src/01_create_audit_trail.ts b/bindings/wasm/audit_trail_wasm/examples/src/01_create_audit_trail.ts index 2ce12cf4..60c3eb62 100644 --- a/bindings/wasm/audit_trail_wasm/examples/src/01_create_audit_trail.ts +++ b/bindings/wasm/audit_trail_wasm/examples/src/01_create_audit_trail.ts @@ -4,9 +4,9 @@ /** * ## Actors * - * - **Admin**: Creates the trail and holds the built-in Admin capability that is + * - **Admin client**: Creates the trail and holds the built-in Admin capability that is * automatically minted on creation. - * - **RecordAdmin**: Receives a RecordAdmin capability bound to their address. Writes + * - **Record admin client**: Receives a RecordAdmin capability bound to their address. Writes * records in subsequent examples. * * Demonstrates how to: @@ -22,38 +22,38 @@ import { createTrailWithSeedRecord, getFundedClient, TEST_GAS_BUDGET } from "./u export async function createAuditTrail(): Promise { console.log("Creating an audit trail"); - // `admin` creates the trail and holds the Admin capability. - // `recordAdmin` receives the RecordAdmin capability. - const admin = await getFundedClient(); - const recordAdmin = await getFundedClient(); + // `adminClient` creates the trail and holds the Admin capability. + // `recordAdminClient` receives the delegated RecordAdmin capability. + const adminClient = await getFundedClient(); + const recordAdminClient = await getFundedClient(); - console.log("Admin address: ", admin.senderAddress()); - console.log("RecordAdmin address: ", recordAdmin.senderAddress()); + console.log("Admin client address: ", adminClient.senderAddress()); + console.log("Record admin client address: ", recordAdminClient.senderAddress()); - const { output: trail, response } = await createTrailWithSeedRecord(admin); + const { output: createdTrail, response } = await createTrailWithSeedRecord(adminClient); - console.log(`Created trail ${trail.id} with transaction ${response.digest}`); - console.log("Immutable metadata:", trail.immutableMetadata); - console.log("Updatable metadata:", trail.updatableMetadata); - console.log("Locking config:", trail.lockingConfig); + console.log(`Created trail ${createdTrail.id} with transaction ${response.digest}`); + console.log("Immutable metadata:", createdTrail.immutableMetadata); + console.log("Updatable metadata:", createdTrail.updatableMetadata); + console.log("Locking config:", createdTrail.lockingConfig); - assert.equal(trail.sequenceNumber, 1n); - assert.ok(trail.immutableMetadata); - assert.equal(trail.immutableMetadata?.name, "Example Audit Trail"); + assert.equal(createdTrail.sequenceNumber, 1n); + assert.ok(createdTrail.immutableMetadata); + assert.equal(createdTrail.immutableMetadata?.name, "Example Audit Trail"); - // Define a RecordAdmin role and issue the capability to recordAdmin's address. - const role = admin.trail(trail.id).access().forRole("RecordAdmin"); - await role + // Admin capability authorization is implicit: adminClient owns the built-in Admin capability. + const recordAdminRole = adminClient.trail(createdTrail.id).access().forRole("RecordAdmin"); + await recordAdminRole .create(PermissionSet.recordAdminPermissions()) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(admin); - await role - .issueCapability(new CapabilityIssueOptions(recordAdmin.senderAddress())) + .buildAndExecute(adminClient); + await recordAdminRole + .issueCapability(new CapabilityIssueOptions(recordAdminClient.senderAddress())) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(admin); + .buildAndExecute(adminClient); - const onChain = await admin.trail(trail.id).get(); - const roleNames = onChain.roles.roles.map((r) => r.name); + const onChainTrail = await adminClient.trail(createdTrail.id).get(); + const roleNames = onChainTrail.roles.roles.map((r) => r.name); console.log("Roles:", roleNames); assert.ok(roleNames.includes("RecordAdmin")); } diff --git a/bindings/wasm/audit_trail_wasm/examples/src/02_add_and_read_records.ts b/bindings/wasm/audit_trail_wasm/examples/src/02_add_and_read_records.ts index 161f788e..85def291 100644 --- a/bindings/wasm/audit_trail_wasm/examples/src/02_add_and_read_records.ts +++ b/bindings/wasm/audit_trail_wasm/examples/src/02_add_and_read_records.ts @@ -4,9 +4,9 @@ /** * ## Actors * - * - **Admin**: Creates the trail, defines the RecordAdmin role, and issues a capability. - * - **RecordAdmin**: Holds the capability and writes records. Reads are also done through - * this client to demonstrate that any address can read, but only the cap holder can write. + * - **Admin client**: Creates the trail, defines the RecordAdmin role, and issues a capability. + * - **Record admin client**: Holds the capability and writes records. Reads use the same client + * to keep the example focused after delegation. * * Demonstrates how to: * 1. Add follow-up records to a trail. @@ -21,49 +21,46 @@ import { createTrailWithSeedRecord, getFundedClient, TEST_GAS_BUDGET } from "./u export async function addAndReadRecords(): Promise { console.log("Adding records and reading them back with pagination"); - // `admin` creates the trail and sets up the role. - // `recordAdmin` holds the capability and writes/reads records. - const admin = await getFundedClient(); - const recordAdmin = await getFundedClient(); + // `adminClient` creates the trail and delegates record writes. + // `recordAdminClient` holds the capability and writes/reads records. + const adminClient = await getFundedClient(); + const recordAdminClient = await getFundedClient(); - const { output: trail } = await createTrailWithSeedRecord(admin); - const trailId = trail.id; + const { output: createdTrail } = await createTrailWithSeedRecord(adminClient); + const trailId = createdTrail.id; - // Create a RecordAdmin role and issue the capability to recordAdmin's address. - const role = admin.trail(trailId).access().forRole("RecordAdmin"); - await role + const recordAdminRole = adminClient.trail(trailId).access().forRole("RecordAdmin"); + await recordAdminRole .create(PermissionSet.recordAdminPermissions()) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(admin); - await role - .issueCapability(new CapabilityIssueOptions(recordAdmin.senderAddress())) + .buildAndExecute(adminClient); + await recordAdminRole + .issueCapability(new CapabilityIssueOptions(recordAdminClient.senderAddress())) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(admin); + .buildAndExecute(adminClient); - // The client automatically finds the capability in recordAdmin's wallet. - const records = recordAdmin.trail(trailId).records(); + // Capability selection is automatic from recordAdminClient's wallet. + const recordAdminRecords = recordAdminClient.trail(trailId).records(); - // Add records - const addedSecond = await records + const addedSecondRecord = await recordAdminRecords .add(Data.fromString("record 2"), "second") .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(recordAdmin); - const addedThird = await records + .buildAndExecute(recordAdminClient); + const addedThirdRecord = await recordAdminRecords .add(Data.fromString("record 3"), "third") .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(recordAdmin); + .buildAndExecute(recordAdminClient); - console.log("Added records:", addedSecond.output, addedThird.output); + console.log("Added records:", addedSecondRecord.output, addedThirdRecord.output); - // Read individual records - const initial = await records.get(0n); - const first = await records.get(addedSecond.output.sequenceNumber); - assert.equal(initial.data.toString(), "seed record"); - assert.equal(first.data.toString(), "record 2"); + const seedRecord = await recordAdminRecords.get(0n); + const secondRecord = await recordAdminRecords.get(addedSecondRecord.output.sequenceNumber); + assert.equal(seedRecord.data.toString(), "seed record"); + assert.equal(secondRecord.data.toString(), "record 2"); - // Paginate - const firstPage = await records.listPage(undefined, 2); - const secondPage = await records.listPage(firstPage.nextCursor, 2); + // Pagination uses the previous page cursor to continue from the next record. + const firstPage = await recordAdminRecords.listPage(undefined, 2); + const secondPage = await recordAdminRecords.listPage(firstPage.nextCursor, 2); console.log("First page:", firstPage); console.log("Second page:", secondPage); diff --git a/bindings/wasm/audit_trail_wasm/examples/src/03_update_metadata.ts b/bindings/wasm/audit_trail_wasm/examples/src/03_update_metadata.ts index 7b3e69b0..0f1fab35 100644 --- a/bindings/wasm/audit_trail_wasm/examples/src/03_update_metadata.ts +++ b/bindings/wasm/audit_trail_wasm/examples/src/03_update_metadata.ts @@ -4,8 +4,8 @@ /** * ## Actors * - * - **Admin**: Creates the trail and sets up the MetadataAdmin role. - * - **MetadataAdmin**: Holds the MetadataAdmin capability and updates the trail's mutable + * - **Admin client**: Creates the trail and sets up the MetadataAdmin role. + * - **Metadata admin client**: Holds the MetadataAdmin capability and updates the trail's mutable * status field. Has no record-write permissions. * * Demonstrates how to: @@ -22,65 +22,65 @@ import { getFundedClient, TEST_GAS_BUDGET } from "./util"; export async function updateMetadata(): Promise { console.log("=== Audit Trail: Update Metadata ===\n"); - // `admin` creates the trail and sets up the role. - // `metadataAdmin` holds the MetadataAdmin capability and updates the status. - const admin = await getFundedClient(); - const metadataAdmin = await getFundedClient(); + // `adminClient` creates the trail and delegates metadata updates. + // `metadataAdminClient` holds the MetadataAdmin capability and updates the status. + const adminClient = await getFundedClient(); + const metadataAdminClient = await getFundedClient(); - const { output: trail } = await admin + const { output: createdTrail } = await adminClient .createTrail() .withTrailMetadata("Shipment Processing", "Tracks the lifecycle of a warehouse shipment") .withUpdatableMetadata("Status: Draft") .withInitialRecordString("Shipment created", "event:created") .finish() .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(admin); + .buildAndExecute(adminClient); - const trailId = trail.id; + const trailId = createdTrail.id; // Delegate metadata updates to a MetadataAdmin role. - const role = admin.trail(trailId).access().forRole("MetadataAdmin"); - await role + const metadataAdminRole = adminClient.trail(trailId).access().forRole("MetadataAdmin"); + await metadataAdminRole .create(PermissionSet.metadataAdminPermissions()) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(admin); - await role - .issueCapability(new CapabilityIssueOptions(metadataAdmin.senderAddress())) + .buildAndExecute(adminClient); + await metadataAdminRole + .issueCapability(new CapabilityIssueOptions(metadataAdminClient.senderAddress())) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(admin); + .buildAndExecute(adminClient); - const before = await admin.trail(trailId).get(); + const trailBeforeUpdate = await adminClient.trail(trailId).get(); console.log("Before update:"); - console.log(" immutable =", before.immutableMetadata); - console.log(" updatable =", before.updatableMetadata, "\n"); + console.log(" immutable =", trailBeforeUpdate.immutableMetadata); + console.log(" updatable =", trailBeforeUpdate.updatableMetadata, "\n"); // MetadataAdmin updates the mutable metadata. - await metadataAdmin + await metadataAdminClient .trail(trailId) .updateMetadata("Status: In Review") .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(metadataAdmin); + .buildAndExecute(metadataAdminClient); - const afterUpdate = await admin.trail(trailId).get(); + const trailAfterUpdate = await adminClient.trail(trailId).get(); console.log("After update:"); - console.log(" immutable =", afterUpdate.immutableMetadata); - console.log(" updatable =", afterUpdate.updatableMetadata, "\n"); + console.log(" immutable =", trailAfterUpdate.immutableMetadata); + console.log(" updatable =", trailAfterUpdate.updatableMetadata, "\n"); - assert.equal(afterUpdate.immutableMetadata?.name, "Shipment Processing"); - assert.equal(afterUpdate.updatableMetadata, "Status: In Review"); + assert.equal(trailAfterUpdate.immutableMetadata?.name, "Shipment Processing"); + assert.equal(trailAfterUpdate.updatableMetadata, "Status: In Review"); // MetadataAdmin clears the mutable metadata. - await metadataAdmin + await metadataAdminClient .trail(trailId) .updateMetadata(undefined) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(metadataAdmin); + .buildAndExecute(metadataAdminClient); - const afterClear = await admin.trail(trailId).get(); + const trailAfterClear = await adminClient.trail(trailId).get(); console.log("After clear:"); - console.log(" immutable =", afterClear.immutableMetadata); - console.log(" updatable =", afterClear.updatableMetadata); + console.log(" immutable =", trailAfterClear.immutableMetadata); + console.log(" updatable =", trailAfterClear.updatableMetadata); - assert.equal(afterClear.immutableMetadata?.name, "Shipment Processing"); - assert.equal(afterClear.updatableMetadata, undefined); + assert.equal(trailAfterClear.immutableMetadata?.name, "Shipment Processing"); + assert.equal(trailAfterClear.updatableMetadata, undefined); } diff --git a/bindings/wasm/audit_trail_wasm/examples/src/04_configure_locking.ts b/bindings/wasm/audit_trail_wasm/examples/src/04_configure_locking.ts index 7b282f36..c44a8b33 100644 --- a/bindings/wasm/audit_trail_wasm/examples/src/04_configure_locking.ts +++ b/bindings/wasm/audit_trail_wasm/examples/src/04_configure_locking.ts @@ -4,10 +4,10 @@ /** * ## Actors * - * - **Admin**: Creates the trail and sets up the LockingAdmin and RecordAdmin roles. - * - **LockingAdmin**: Controls write and delete locks. Holds the LockingAdmin capability. - * - **RecordAdmin**: Writes records. Used to demonstrate that the write lock is enforced - * per-sender, not just checked by the admin. + * - **Admin client**: Creates the trail and sets up the LockingAdmin and RecordAdmin roles. + * - **Locking admin client**: Controls write and delete locks. Holds the LockingAdmin capability. + * - **Record admin client**: Writes records. Used to demonstrate that the write lock is enforced + * per-sender, not just checked by the admin client. * * Demonstrates how to: * 1. Delegate locking updates through a LockingAdmin role. @@ -30,95 +30,94 @@ import { createTrailWithSeedRecord, getFundedClient, TEST_GAS_BUDGET } from "./u export async function configureLocking(): Promise { console.log("=== Audit Trail: Configure Locking ===\n"); - // `admin` creates the trail and sets up roles. - // `lockingAdmin` controls locks; `recordAdmin` writes records. - const admin = await getFundedClient(); - const lockingAdmin = await getFundedClient(); - const recordAdmin = await getFundedClient(); + // `adminClient` creates the trail and delegates separate lock/write authority. + // `lockingAdminClient` controls locks; `recordAdminClient` writes records. + const adminClient = await getFundedClient(); + const lockingAdminClient = await getFundedClient(); + const recordAdminClient = await getFundedClient(); - const { output: trail } = await createTrailWithSeedRecord(admin); - const trailId = trail.id; + const { output: createdTrail } = await createTrailWithSeedRecord(adminClient); + const trailId = createdTrail.id; - // Create LockingAdmin and RecordAdmin roles. - const lockingRole = admin.trail(trailId).access().forRole("LockingAdmin"); - await lockingRole + const lockingAdminRole = adminClient.trail(trailId).access().forRole("LockingAdmin"); + await lockingAdminRole .create(PermissionSet.lockingAdminPermissions()) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(admin); - await lockingRole - .issueCapability(new CapabilityIssueOptions(lockingAdmin.senderAddress())) + .buildAndExecute(adminClient); + await lockingAdminRole + .issueCapability(new CapabilityIssueOptions(lockingAdminClient.senderAddress())) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(admin); + .buildAndExecute(adminClient); - const recordRole = admin.trail(trailId).access().forRole("RecordAdmin"); - await recordRole + const recordAdminRole = adminClient.trail(trailId).access().forRole("RecordAdmin"); + await recordAdminRole .create(PermissionSet.recordAdminPermissions()) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(admin); - await recordRole - .issueCapability(new CapabilityIssueOptions(recordAdmin.senderAddress())) + .buildAndExecute(adminClient); + await recordAdminRole + .issueCapability(new CapabilityIssueOptions(recordAdminClient.senderAddress())) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(admin); + .buildAndExecute(adminClient); // LockingAdmin freezes writes. - await lockingAdmin + await lockingAdminClient .trail(trailId) .locking() .updateWriteLock(TimeLock.withInfinite()) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(lockingAdmin); + .buildAndExecute(lockingAdminClient); - const locked = await admin.trail(trailId).get(); - console.log("Write lock after update:", locked.lockingConfig.writeLock, "\n"); - assert.equal(locked.lockingConfig.writeLock.type, TimeLock.withInfinite().type); + const lockedTrail = await adminClient.trail(trailId).get(); + console.log("Write lock after update:", lockedTrail.lockingConfig.writeLock, "\n"); + assert.equal(lockedTrail.lockingConfig.writeLock.type, TimeLock.withInfinite().type); // RecordAdmin attempts to add a record while locked — should fail. - const blockedAdd = await recordAdmin + const blockedAdd = await recordAdminClient .trail(trailId) .records() .add(Data.fromString("This write should fail"), "blocked") .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(recordAdmin) + .buildAndExecute(recordAdminClient) .catch(() => null); assert.equal(blockedAdd, null, "write lock should block adding records"); // LockingAdmin lifts the write lock. - await lockingAdmin + await lockingAdminClient .trail(trailId) .locking() .updateWriteLock(TimeLock.withNone()) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(lockingAdmin); + .buildAndExecute(lockingAdminClient); - const added = await recordAdmin + const recordAddedAfterUnlock = await recordAdminClient .trail(trailId) .records() .add(Data.fromString("Write lock lifted"), "event:resumed") .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(recordAdmin); - console.log("Added record", added.output.sequenceNumber, "after clearing the write lock.\n"); + .buildAndExecute(recordAdminClient); + console.log("Added record", recordAddedAfterUnlock.output.sequenceNumber, "after clearing the write lock.\n"); // LockingAdmin configures deletion window and trail lock. - await lockingAdmin + await lockingAdminClient .trail(trailId) .locking() .updateDeleteRecordWindow(LockingWindow.withCountBased(BigInt(2))) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(lockingAdmin); - await lockingAdmin + .buildAndExecute(lockingAdminClient); + await lockingAdminClient .trail(trailId) .locking() .updateDeleteTrailLock(TimeLock.withInfinite()) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(lockingAdmin); + .buildAndExecute(lockingAdminClient); - const finalState = await admin.trail(trailId).get(); + const finalTrail = await adminClient.trail(trailId).get(); console.log("Final locking config:"); - console.log(" delete_record_window =", finalState.lockingConfig.deleteRecordWindow); - console.log(" delete_trail_lock =", finalState.lockingConfig.deleteTrailLock); - console.log(" write_lock =", finalState.lockingConfig.writeLock); + console.log(" delete_record_window =", finalTrail.lockingConfig.deleteRecordWindow); + console.log(" delete_trail_lock =", finalTrail.lockingConfig.deleteTrailLock); + console.log(" write_lock =", finalTrail.lockingConfig.writeLock); - assert.equal(finalState.lockingConfig.deleteRecordWindow.type, LockingWindow.withCountBased(BigInt(2)).type); - assert.equal(finalState.lockingConfig.deleteTrailLock.type, TimeLock.withInfinite().type); - assert.equal(finalState.lockingConfig.writeLock.type, TimeLock.withNone().type); + assert.equal(finalTrail.lockingConfig.deleteRecordWindow.type, LockingWindow.withCountBased(BigInt(2)).type); + assert.equal(finalTrail.lockingConfig.deleteTrailLock.type, TimeLock.withInfinite().type); + assert.equal(finalTrail.lockingConfig.writeLock.type, TimeLock.withNone().type); } diff --git a/bindings/wasm/audit_trail_wasm/examples/src/05_manage_access.ts b/bindings/wasm/audit_trail_wasm/examples/src/05_manage_access.ts index 3fd1a0f0..771da219 100644 --- a/bindings/wasm/audit_trail_wasm/examples/src/05_manage_access.ts +++ b/bindings/wasm/audit_trail_wasm/examples/src/05_manage_access.ts @@ -4,9 +4,9 @@ /** * ## Actors * - * - **Admin**: Creates and updates roles, issues capabilities, revokes and destroys them, + * - **Admin client**: Creates and updates roles, issues capabilities, revokes and destroys them, * and finally deletes the role once it is no longer needed. - * - **OperationsUser**: The subject of all capability issuance. Capabilities are bound to + * - **Operations user client**: The subject of all capability issuance. Capabilities are bound to * this address to demonstrate that revocation immediately blocks their access. * * Demonstrates how to: @@ -23,23 +23,22 @@ import { createTrailWithSeedRecord, getFundedClient, TEST_GAS_BUDGET } from "./u export async function manageAccess(): Promise { console.log("=== Audit Trail: Manage Access ===\n"); - // `admin` manages roles and the full capability lifecycle. - // `operationsUser` is the target of all capability issuance. - const admin = await getFundedClient(); - const operationsUser = await getFundedClient(); + // `adminClient` manages roles and the full capability lifecycle. + // `operationsUserClient` is the target of constrained capability issuance. + const adminClient = await getFundedClient(); + const operationsUserClient = await getFundedClient(); - const { output: trail } = await createTrailWithSeedRecord(admin); - const trailId = trail.id; + const { output: createdTrail } = await createTrailWithSeedRecord(adminClient); + const trailId = createdTrail.id; - // 1. Create the role - const createdRole = await admin + const createdOperationsRole = await adminClient .trail(trailId) .access() .forRole("Operations") .create(PermissionSet.recordAdminPermissions()) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(admin); - console.log("Created role:", createdRole.output.role, "\n"); + .buildAndExecute(adminClient); + console.log("Created role:", createdOperationsRole.output.role, "\n"); // 2. Update the role permissions const updatedPermissionValues = [ @@ -48,86 +47,92 @@ export async function manageAccess(): Promise { Permission.DeleteAllRecords, ]; const updatedPermissions = new PermissionSet(updatedPermissionValues); - const updatedRole = await admin + const updatedOperationsRole = await adminClient .trail(trailId) .access() .forRole("Operations") .updatePermissions(updatedPermissions) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(admin); - console.log("Updated role permissions:", updatedRole.output.permissions.permissions.map((p) => p.toString())); + .buildAndExecute(adminClient); + console.log( + "Updated role permissions:", + updatedOperationsRole.output.permissions.permissions.map((p) => p.toString()), + ); - // 3. Issue a constrained capability bound to operationsUser's address. - const constrainedCap = await admin + // 3. Issue a capability bound to operationsUserClient's address and expiry window. + const constrainedOperationsCapability = await adminClient .trail(trailId) .access() .forRole("Operations") .issueCapability( - new CapabilityIssueOptions(operationsUser.senderAddress(), undefined, BigInt(4_102_444_800_000)), + new CapabilityIssueOptions(operationsUserClient.senderAddress(), undefined, BigInt(4_102_444_800_000)), ) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(admin); + .buildAndExecute(adminClient); console.log("\nIssued constrained capability:"); - console.log(" id =", constrainedCap.output.capabilityId); - console.log(" issued_to =", constrainedCap.output.issuedTo); - console.log(" valid_until =", constrainedCap.output.validUntil, "\n"); + console.log(" id =", constrainedOperationsCapability.output.capabilityId); + console.log(" issued_to =", constrainedOperationsCapability.output.issuedTo); + console.log(" valid_until =", constrainedOperationsCapability.output.validUntil, "\n"); // Verify the on-chain role matches the updated permissions. - const onChain = await admin.trail(trailId).get(); - const opsRole = onChain.roles.roles.find((r) => r.name === "Operations"); - assert.ok(opsRole, "Operations role must exist"); - const opsPermSet = new Set(opsRole?.permissions.map((p) => p.toString())); + const onChainTrail = await adminClient.trail(trailId).get(); + const operationsRole = onChainTrail.roles.roles.find((r) => r.name === "Operations"); + assert.ok(operationsRole, "Operations role must exist"); + const operationsPermissionSet = new Set(operationsRole?.permissions.map((p) => p.toString())); for (const perm of updatedPermissionValues) { - assert(opsPermSet.has(perm.toString()), `role should contain ${perm}`); + assert(operationsPermissionSet.has(perm.toString()), `role should contain ${perm}`); } // 4. Revoke the constrained capability. - await admin + await adminClient .trail(trailId) .access() - .revokeCapability(constrainedCap.output.capabilityId, constrainedCap.output.validUntil) + .revokeCapability( + constrainedOperationsCapability.output.capabilityId, + constrainedOperationsCapability.output.validUntil, + ) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(admin); - console.log("Revoked capability", constrainedCap.output.capabilityId, "\n"); + .buildAndExecute(adminClient); + console.log("Revoked capability", constrainedOperationsCapability.output.capabilityId, "\n"); - // 5. Issue a disposable capability (to admin) and destroy it. + // 5. Issue a disposable capability to the Admin actor and destroy it. // destroyCapability consumes the capability object, so the signer must own it. - // The capability is issued to admin so admin can destroy it directly. - const disposableCap = await admin + // The capability is issued to adminClient so adminClient can destroy it directly. + const disposableOperationsCapability = await adminClient .trail(trailId) .access() .forRole("Operations") - .issueCapability(new CapabilityIssueOptions(admin.senderAddress())) + .issueCapability(new CapabilityIssueOptions(adminClient.senderAddress())) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(admin); - await admin + .buildAndExecute(adminClient); + await adminClient .trail(trailId) .access() - .destroyCapability(disposableCap.output.capabilityId) + .destroyCapability(disposableOperationsCapability.output.capabilityId) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(admin); - console.log("Destroyed capability", disposableCap.output.capabilityId, "\n"); + .buildAndExecute(adminClient); + console.log("Destroyed capability", disposableOperationsCapability.output.capabilityId, "\n"); // 6. Clean up the revoked-capability registry entry so the role can be removed. - await admin + await adminClient .trail(trailId) .access() .cleanupRevokedCapabilities() .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(admin); + .buildAndExecute(adminClient); console.log("Cleaned up revoked capability registry entries.\n"); // 7. Delete the role. - await admin + await adminClient .trail(trailId) .access() .forRole("Operations") .delete() .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(admin); - const afterDelete = await admin.trail(trailId).get(); - const opsRoleAfterDelete = afterDelete.roles.roles.find((r) => r.name === "Operations"); - assert.equal(opsRoleAfterDelete, undefined, "role should be removed from the trail"); + .buildAndExecute(adminClient); + const trailAfterDelete = await adminClient.trail(trailId).get(); + const operationsRoleAfterDelete = trailAfterDelete.roles.roles.find((r) => r.name === "Operations"); + assert.equal(operationsRoleAfterDelete, undefined, "role should be removed from the trail"); console.log("Removed the custom role after its capability lifecycle completed."); } diff --git a/bindings/wasm/audit_trail_wasm/examples/src/06_delete_records.ts b/bindings/wasm/audit_trail_wasm/examples/src/06_delete_records.ts index 556e6631..91c0058b 100644 --- a/bindings/wasm/audit_trail_wasm/examples/src/06_delete_records.ts +++ b/bindings/wasm/audit_trail_wasm/examples/src/06_delete_records.ts @@ -4,8 +4,8 @@ /** * ## Actors * - * - **Admin**: Creates the trail and sets up the RecordMaintenance role. - * - **RecordMaintainer**: Holds the RecordMaintenance capability. Adds records and then + * - **Admin client**: Creates the trail and sets up the RecordMaintenance role. + * - **Record maintainer client**: Holds the RecordMaintenance capability. Adds records and then * deletes them individually and in batch. * * Demonstrates how to: @@ -29,12 +29,12 @@ import { getFundedClient, TEST_GAS_BUDGET } from "./util"; export async function deleteRecords(): Promise { console.log("=== Audit Trail: Delete Records ===\n"); - // `admin` creates the trail and sets up the role. - // `recordMaintainer` adds and deletes records. - const admin = await getFundedClient(); - const recordMaintainer = await getFundedClient(); + // `adminClient` creates the trail and delegates record maintenance. + // `recordMaintainerClient` adds and deletes records. + const adminClient = await getFundedClient(); + const recordMaintainerClient = await getFundedClient(); - const { output: trail } = await admin + const { output: createdTrail } = await adminClient .createTrail() .withTrailMetadata("Delete Records Example", "Trail configured to demonstrate record deletions") .withUpdatableMetadata("Status: Active") @@ -44,54 +44,58 @@ export async function deleteRecords(): Promise { .withInitialRecordString("Seed record", "v0") .finish() .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(admin); + .buildAndExecute(adminClient); - const trailId = trail.id; + const trailId = createdTrail.id; - // Create a role with delete permissions and issue to recordMaintainer. - const role = admin.trail(trailId).access().forRole("RecordMaintenance"); - await role + const recordMaintenanceRole = adminClient.trail(trailId).access().forRole("RecordMaintenance"); + await recordMaintenanceRole .create(new PermissionSet([Permission.AddRecord, Permission.DeleteRecord, Permission.DeleteAllRecords])) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(admin); - await role - .issueCapability(new CapabilityIssueOptions(recordMaintainer.senderAddress())) + .buildAndExecute(adminClient); + await recordMaintenanceRole + .issueCapability(new CapabilityIssueOptions(recordMaintainerClient.senderAddress())) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(admin); + .buildAndExecute(adminClient); - const records = recordMaintainer.trail(trailId).records(); + const maintenanceRecords = recordMaintainerClient.trail(trailId).records(); // RecordMaintainer adds records. - const rec1 = await records + const firstMaintainedRecord = await maintenanceRecords .add(Data.fromString("First record"), "v1") .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(recordMaintainer); - const rec2 = await records + .buildAndExecute(recordMaintainerClient); + const secondMaintainedRecord = await maintenanceRecords .add(Data.fromString("Second record"), "v2") .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(recordMaintainer); + .buildAndExecute(recordMaintainerClient); - console.log("Added records", rec1.output.sequenceNumber, "and", rec2.output.sequenceNumber); + console.log( + "Added records", + firstMaintainedRecord.output.sequenceNumber, + "and", + secondMaintainedRecord.output.sequenceNumber, + ); // Delete a single record. - const deleted = await records - .delete(rec1.output.sequenceNumber) + const deletedRecord = await maintenanceRecords + .delete(firstMaintainedRecord.output.sequenceNumber) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(recordMaintainer); - console.log("Deleted record", deleted.output.sequenceNumber); + .buildAndExecute(recordMaintainerClient); + console.log("Deleted record", deletedRecord.output.sequenceNumber); - let count = await records.recordCount(); - console.log("Record count after single delete:", count); - assert.equal(count, 2n); // seed + rec2 + let recordCount = await maintenanceRecords.recordCount(); + console.log("Record count after single delete:", recordCount); + assert.equal(recordCount, 2n); // seed + secondMaintainedRecord // Batch-delete remaining records. - const batchDeleted = await records + const batchDeletedRecords = await maintenanceRecords .deleteBatch(BigInt(10)) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(recordMaintainer); - console.log("Batch deleted", batchDeleted.output, "records"); + .buildAndExecute(recordMaintainerClient); + console.log("Batch deleted", batchDeletedRecords.output, "records"); - count = await records.recordCount(); - assert.equal(count, 0n, "all records should be deleted after batch"); - console.log("Record count after batch delete:", count); + recordCount = await maintenanceRecords.recordCount(); + assert.equal(recordCount, 0n, "all records should be deleted after batch"); + console.log("Record count after batch delete:", recordCount); } diff --git a/bindings/wasm/audit_trail_wasm/examples/src/07_access_read_only_methods.ts b/bindings/wasm/audit_trail_wasm/examples/src/07_access_read_only_methods.ts index 8086199f..fafa3098 100644 --- a/bindings/wasm/audit_trail_wasm/examples/src/07_access_read_only_methods.ts +++ b/bindings/wasm/audit_trail_wasm/examples/src/07_access_read_only_methods.ts @@ -4,8 +4,8 @@ /** * ## Actors * - * - **Admin**: Creates the trail and sets up the RecordAdmin role. - * - **RecordAdmin**: Adds one follow-up record. All subsequent operations are read-only + * - **Admin client**: Creates the trail and sets up the RecordAdmin role. + * - **Record admin client**: Adds one follow-up record. All subsequent operations are read-only * and can be performed by any address — no capability required. * * Demonstrates how to: @@ -29,12 +29,12 @@ import { getFundedClient, TEST_GAS_BUDGET } from "./util"; export async function accessReadOnlyMethods(): Promise { console.log("=== Audit Trail: Read-Only Inspection ===\n"); - // `admin` creates the trail and sets up the role. - // `recordAdmin` adds the follow-up record. - const admin = await getFundedClient(); - const recordAdmin = await getFundedClient(); + // `adminClient` creates the trail and delegates one record write. + // `recordAdminClient` adds the follow-up record. + const adminClient = await getFundedClient(); + const recordAdminClient = await getFundedClient(); - const { output: created } = await admin + const { output: createdTrail } = await adminClient .createTrail() .withTrailMetadata("Operations Trail", "Used to inspect read-only accessors") .withUpdatableMetadata("Status: Active") @@ -44,43 +44,42 @@ export async function accessReadOnlyMethods(): Promise { .withInitialRecordString("Initial record", "event:created") .finish() .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(admin); + .buildAndExecute(adminClient); - const trailId = created.id; + const trailId = createdTrail.id; - // Create RecordAdmin role and issue to recordAdmin. - const role = admin.trail(trailId).access().forRole("RecordAdmin"); - await role + const recordAdminRole = adminClient.trail(trailId).access().forRole("RecordAdmin"); + await recordAdminRole .create(PermissionSet.recordAdminPermissions()) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(admin); - await role - .issueCapability(new CapabilityIssueOptions(recordAdmin.senderAddress())) + .buildAndExecute(adminClient); + await recordAdminRole + .issueCapability(new CapabilityIssueOptions(recordAdminClient.senderAddress())) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(admin); + .buildAndExecute(adminClient); // RecordAdmin adds a follow-up record. - await recordAdmin + await recordAdminClient .trail(trailId) .records() .add(Data.fromString("Follow-up record"), "event:updated") .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(recordAdmin); + .buildAndExecute(recordAdminClient); // All reads below require no capability — any address can inspect the trail. - const onChain = await admin.trail(trailId).get(); + const onChainTrail = await adminClient.trail(trailId).get(); console.log("Trail summary:"); - console.log(" id =", onChain.id); - console.log(" creator =", onChain.creator); - console.log(" created_at =", onChain.createdAt); - console.log(" sequence_number =", onChain.sequenceNumber); - console.log(" immutable_metadata =", onChain.immutableMetadata); - console.log(" updatable_metadata =", onChain.updatableMetadata, "\n"); + console.log(" id =", onChainTrail.id); + console.log(" creator =", onChainTrail.creator); + console.log(" created_at =", onChainTrail.createdAt); + console.log(" sequence_number =", onChainTrail.sequenceNumber); + console.log(" immutable_metadata =", onChainTrail.immutableMetadata); + console.log(" updatable_metadata =", onChainTrail.updatableMetadata, "\n"); - console.log("Roles:", onChain.roles.roles.map((r) => r.name)); - console.log("Locking config:", onChain.lockingConfig, "\n"); + console.log("Roles:", onChainTrail.roles.roles.map((r) => r.name)); + console.log("Locking config:", onChainTrail.lockingConfig, "\n"); - const trailHandle = admin.trail(trailId); + const trailHandle = adminClient.trail(trailId); const count = await trailHandle.records().recordCount(); const initialRecord = await trailHandle.records().get(0n); const firstPage = await trailHandle.records().listPage(undefined, 10); diff --git a/bindings/wasm/audit_trail_wasm/examples/src/08_delete_audit_trail.ts b/bindings/wasm/audit_trail_wasm/examples/src/08_delete_audit_trail.ts index 2e594d09..8cd2ab56 100644 --- a/bindings/wasm/audit_trail_wasm/examples/src/08_delete_audit_trail.ts +++ b/bindings/wasm/audit_trail_wasm/examples/src/08_delete_audit_trail.ts @@ -4,8 +4,8 @@ /** * ## Actors * - * - **Admin**: Creates the trail and sets up the MaintenanceAdmin role. - * - **MaintenanceAdmin**: Holds delete permissions. Attempts (and fails) to delete the + * - **Admin client**: Creates the trail and sets up the MaintenanceAdmin role. + * - **Maintenance admin client**: Holds delete permissions. Attempts (and fails) to delete the * non-empty trail, then batch-deletes all records before removing the trail itself. * * Demonstrates how to: @@ -21,32 +21,31 @@ import { getFundedClient, TEST_GAS_BUDGET } from "./util"; export async function deleteAuditTrail(): Promise { console.log("=== Audit Trail: Delete Trail ===\n"); - // `admin` creates the trail and sets up the role. - // `maintenanceAdmin` empties and deletes the trail. - const admin = await getFundedClient(); - const maintenanceAdmin = await getFundedClient(); + // `adminClient` creates the trail and delegates trail maintenance. + // `maintenanceAdminClient` empties and deletes the trail. + const adminClient = await getFundedClient(); + const maintenanceAdminClient = await getFundedClient(); - const { output: created } = await admin + const { output: createdTrail } = await adminClient .createTrail() .withInitialRecordString("Initial record", "event:created") .finish() .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(admin); + .buildAndExecute(adminClient); - const trailId = created.id; + const trailId = createdTrail.id; - // Create a role with delete permissions and issue to maintenanceAdmin. - const role = admin.trail(trailId).access().forRole("MaintenanceAdmin"); - await role + const maintenanceAdminRole = adminClient.trail(trailId).access().forRole("MaintenanceAdmin"); + await maintenanceAdminRole .create(new PermissionSet([Permission.DeleteAllRecords, Permission.DeleteAuditTrail])) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(admin); - await role - .issueCapability(new CapabilityIssueOptions(maintenanceAdmin.senderAddress())) + .buildAndExecute(adminClient); + await maintenanceAdminRole + .issueCapability(new CapabilityIssueOptions(maintenanceAdminClient.senderAddress())) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(admin); + .buildAndExecute(adminClient); - const maintenanceTrail = maintenanceAdmin.trail(trailId); + const maintenanceTrail = maintenanceAdminClient.trail(trailId); // 1. Attempting to delete a non-empty trail should fail. let deleteWhileNonEmptySucceeded = false; @@ -54,7 +53,7 @@ export async function deleteAuditTrail(): Promise { await maintenanceTrail .deleteAuditTrail() .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(maintenanceAdmin); + .buildAndExecute(maintenanceAdminClient); deleteWhileNonEmptySucceeded = true; } catch { // Expected @@ -67,7 +66,7 @@ export async function deleteAuditTrail(): Promise { .records() .deleteBatch(BigInt(10)) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(maintenanceAdmin); + .buildAndExecute(maintenanceAdminClient); console.log("Deleted", deletedRecords.output, "record(s) before trail removal.\n"); const count = await maintenanceTrail.records().recordCount(); @@ -77,7 +76,7 @@ export async function deleteAuditTrail(): Promise { const deletedTrail = await maintenanceTrail .deleteAuditTrail() .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(maintenanceAdmin); + .buildAndExecute(maintenanceAdminClient); console.log("Trail deleted:"); console.log(" trail_id =", deletedTrail.output.trailId); console.log(" timestamp =", deletedTrail.output.timestamp); diff --git a/bindings/wasm/audit_trail_wasm/examples/src/advanced/09_tagged_records.ts b/bindings/wasm/audit_trail_wasm/examples/src/advanced/09_tagged_records.ts index 57b8e87a..c4c0f1d6 100644 --- a/bindings/wasm/audit_trail_wasm/examples/src/advanced/09_tagged_records.ts +++ b/bindings/wasm/audit_trail_wasm/examples/src/advanced/09_tagged_records.ts @@ -4,9 +4,9 @@ /** * ## Actors * - * - **Admin**: Creates the trail, defines the FinanceWriter role restricted to the - * `finance` tag, and issues a capability bound to `financeWriter`'s address. - * - **FinanceWriter**: Holds the address-bound capability. Can add `finance`-tagged + * - **Admin client**: Creates the trail, defines the FinanceWriter role restricted to the + * `finance` tag, and issues a capability bound to `financeWriterClient`'s address. + * - **Finance writer client**: Holds the address-bound capability. Can add `finance`-tagged * records but is blocked from writing `legal`-tagged records. * * Demonstrates how to: @@ -23,49 +23,53 @@ import { getFundedClient, TEST_GAS_BUDGET } from "../util"; export async function taggedRecords(): Promise { console.log("=== Audit Trail Advanced: Tagged Records ===\n"); - const admin = await getFundedClient(); - const financeWriter = await getFundedClient(); + const adminClient = await getFundedClient(); + const financeWriterClient = await getFundedClient(); - const { output: created } = await admin + const { output: createdTrail } = await adminClient .createTrail() .withRecordTags(["finance", "legal"]) .withInitialRecordString("Trail created", "event:created") .finish() .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(admin); + .buildAndExecute(adminClient); - const trailId = created.id; + const trailId = createdTrail.id; - // Create a role restricted to the "finance" tag. - const role = admin.trail(trailId).access().forRole("FinanceWriter"); - await role + // The role is scoped to the "finance" tag before the capability is issued. + const financeWriterRole = adminClient.trail(trailId).access().forRole("FinanceWriter"); + await financeWriterRole .create(new PermissionSet([Permission.AddRecord]), new RoleTags(["finance"])) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(admin); + .buildAndExecute(adminClient); - const issued = await role - .issueCapability(new CapabilityIssueOptions(financeWriter.senderAddress())) + const financeWriterCapability = await financeWriterRole + .issueCapability(new CapabilityIssueOptions(financeWriterClient.senderAddress())) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(admin); + .buildAndExecute(adminClient); console.log( "Issued FinanceWriter capability", - issued.output.capabilityId, + financeWriterCapability.output.capabilityId, "to", - financeWriter.senderAddress(), + financeWriterClient.senderAddress(), "\n", ); - // The client automatically finds the capability in financeWriter's wallet. - const financeRecords = financeWriter.trail(trailId).records(); + // Capability selection is automatic from financeWriterClient's wallet. + const financeRecords = financeWriterClient.trail(trailId).records(); // Add a record with the allowed tag. - const added = await financeRecords + const addedFinanceRecord = await financeRecords .add(Data.fromString("Invoice approved"), "department:finance", "finance") .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(financeWriter); + .buildAndExecute(financeWriterClient); - console.log("Added tagged record at sequence number", added.output.sequenceNumber, "with tag \"finance\".\n"); + console.log( + "Added tagged record at sequence number", + addedFinanceRecord.output.sequenceNumber, + "with tag \"finance\".\n", + ); // Attempt to add a record with a different tag — should fail. let wrongTagSucceeded = false; @@ -73,14 +77,14 @@ export async function taggedRecords(): Promise { await financeRecords .add(Data.fromString("Legal review completed"), "department:legal", "legal") .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(financeWriter); + .buildAndExecute(financeWriterClient); wrongTagSucceeded = true; } catch { // Expected } assert.equal(wrongTagSucceeded, false, "a finance-scoped role must not add a legal-tagged record"); - const financeRecord = await financeRecords.get(added.output.sequenceNumber); + const financeRecord = await financeRecords.get(addedFinanceRecord.output.sequenceNumber); console.log("Stored tagged record:", financeRecord); assert.equal(financeRecord.tag, "finance"); } diff --git a/bindings/wasm/audit_trail_wasm/examples/src/advanced/10_capability_constraints.ts b/bindings/wasm/audit_trail_wasm/examples/src/advanced/10_capability_constraints.ts index 4f59c46f..1f132e99 100644 --- a/bindings/wasm/audit_trail_wasm/examples/src/advanced/10_capability_constraints.ts +++ b/bindings/wasm/audit_trail_wasm/examples/src/advanced/10_capability_constraints.ts @@ -4,11 +4,11 @@ /** * ## Actors * - * - **Admin**: Creates the trail, defines the RecordAdmin role, and issues a capability - * bound specifically to `intendedWriter`'s address. Also performs revocation. - * - **IntendedWriter**: The authorised holder. Writes a record successfully before + * - **Admin client**: Creates the trail, defines the RecordAdmin role, and issues a capability + * bound specifically to `intendedWriterClient`'s address. Also performs revocation. + * - **Intended writer client**: The authorised holder. Writes a record successfully before * revocation, then is blocked after the capability is revoked. - * - **WrongWriter**: An unauthorised actor who attempts to use the address-bound capability. + * - **Wrong writer client**: An unauthorised actor who attempts to use the address-bound capability. * All write attempts are rejected by the Move contract. * * Demonstrates how to: @@ -24,42 +24,47 @@ import { createTrailWithSeedRecord, getFundedClient, TEST_GAS_BUDGET } from "../ export async function capabilityConstraints(): Promise { console.log("=== Audit Trail Advanced: Capability Constraints ===\n"); - const admin = await getFundedClient(); - const intendedWriter = await getFundedClient(); - const wrongWriter = await getFundedClient(); + const adminClient = await getFundedClient(); + const intendedWriterClient = await getFundedClient(); + const wrongWriterClient = await getFundedClient(); - const { output: created } = await createTrailWithSeedRecord(admin); - const trailId = created.id; + const { output: createdTrail } = await createTrailWithSeedRecord(adminClient); + const trailId = createdTrail.id; - // Create a RecordAdmin role. - await admin + // Create the role before delegating the address-bound capability. + await adminClient .trail(trailId) .access() .forRole("RecordAdmin") .create(PermissionSet.recordAdminPermissions()) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(admin); + .buildAndExecute(adminClient); - // Issue a capability bound to the intended writer's address. - const issued = await admin + const recordAdminCapability = await adminClient .trail(trailId) .access() .forRole("RecordAdmin") - .issueCapability(new CapabilityIssueOptions(intendedWriter.senderAddress())) + .issueCapability(new CapabilityIssueOptions(intendedWriterClient.senderAddress())) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(admin); + .buildAndExecute(adminClient); - console.log("Issued capability", issued.output.capabilityId, "to", intendedWriter.senderAddress(), "\n"); + console.log( + "Issued capability", + recordAdminCapability.output.capabilityId, + "to", + intendedWriterClient.senderAddress(), + "\n", + ); // The wrong wallet should not be able to add a record. let wrongWriterSucceeded = false; try { - await wrongWriter + await wrongWriterClient .trail(trailId) .records() .add(Data.fromString("Wrong writer"), undefined, undefined) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(wrongWriter); + .buildAndExecute(wrongWriterClient); wrongWriterSucceeded = true; } catch { // Expected @@ -67,37 +72,41 @@ export async function capabilityConstraints(): Promise { assert.equal(wrongWriterSucceeded, false, "a capability bound to another address must not be usable"); // The intended writer CAN add a record. - const added = await intendedWriter + const authorizedRecord = await intendedWriterClient .trail(trailId) .records() .add(Data.fromString("Authorized writer"), undefined, undefined) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(intendedWriter); + .buildAndExecute(intendedWriterClient); - console.log("Bound holder added record", added.output.sequenceNumber, "successfully.\n"); + console.log("Bound holder added record", authorizedRecord.output.sequenceNumber, "successfully.\n"); // Revoke the capability. - await admin + await adminClient .trail(trailId) .access() - .revokeCapability(issued.output.capabilityId, issued.output.validUntil) + .revokeCapability(recordAdminCapability.output.capabilityId, recordAdminCapability.output.validUntil) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(admin); + .buildAndExecute(adminClient); // The intended writer should no longer be able to add a record. let revokedSucceeded = false; try { - await intendedWriter + await intendedWriterClient .trail(trailId) .records() .add(Data.fromString("Should fail after revoke"), undefined, undefined) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(intendedWriter); + .buildAndExecute(intendedWriterClient); revokedSucceeded = true; } catch { // Expected } assert.equal(revokedSucceeded, false, "revoked capabilities must no longer authorize record writes"); - console.log("Revoked capability", issued.output.capabilityId, "and verified it can no longer be used."); + console.log( + "Revoked capability", + recordAdminCapability.output.capabilityId, + "and verified it can no longer be used.", + ); } diff --git a/bindings/wasm/audit_trail_wasm/examples/src/advanced/11_manage_record_tags.ts b/bindings/wasm/audit_trail_wasm/examples/src/advanced/11_manage_record_tags.ts index 3b1f6b78..1c569081 100644 --- a/bindings/wasm/audit_trail_wasm/examples/src/advanced/11_manage_record_tags.ts +++ b/bindings/wasm/audit_trail_wasm/examples/src/advanced/11_manage_record_tags.ts @@ -4,10 +4,10 @@ /** * ## Actors * - * - **Admin**: Creates the trail and manages roles. - * - **TagAdmin**: Holds the TagAdmin capability. Adds and removes entries from the trail's + * - **Admin client**: Creates the trail and manages roles. + * - **Tag admin client**: Holds the TagAdmin capability. Adds and removes entries from the trail's * tag registry. - * - **FinanceWriter**: Holds a `finance`-scoped RecordAdmin capability. Writes a + * - **Finance writer client**: Holds a `finance`-scoped RecordAdmin capability. Writes a * `finance`-tagged record that keeps the `finance` tag in use and therefore unremovable. * * Demonstrates how to: @@ -23,69 +23,73 @@ import { getFundedClient, TEST_GAS_BUDGET } from "../util"; export async function manageRecordTags(): Promise { console.log("=== Audit Trail Advanced: Manage Record Tags ===\n"); - // `admin` creates the trail and manages roles. - // `tagAdmin` adds/removes tags; `financeWriter` writes tagged records. - const admin = await getFundedClient(); - const tagAdmin = await getFundedClient(); - const financeWriter = await getFundedClient(); + // `adminClient` creates the trail and manages roles. + // `tagAdminClient` manages tags; `financeWriterClient` writes tagged records. + const adminClient = await getFundedClient(); + const tagAdminClient = await getFundedClient(); + const financeWriterClient = await getFundedClient(); - const { output: created } = await admin + const { output: createdTrail } = await adminClient .createTrail() .withRecordTags(["finance"]) .withInitialRecordString("Trail created") .finish() .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(admin); + .buildAndExecute(adminClient); - const trailId = created.id; + const trailId = createdTrail.id; // Delegate tag management to a TagAdmin role. - const tagAdminRole = admin.trail(trailId).access().forRole("TagAdmin"); + const tagAdminRole = adminClient.trail(trailId).access().forRole("TagAdmin"); await tagAdminRole .create(PermissionSet.tagAdminPermissions()) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(admin); + .buildAndExecute(adminClient); await tagAdminRole - .issueCapability(new CapabilityIssueOptions(tagAdmin.senderAddress())) + .issueCapability(new CapabilityIssueOptions(tagAdminClient.senderAddress())) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(admin); + .buildAndExecute(adminClient); - // TagAdmin adds a new tag. - await tagAdmin.trail(trailId).tags().add("legal").withGasBudget(TEST_GAS_BUDGET).buildAndExecute(tagAdmin); + // TagAdmin adds a new tag to the registry before any role or record uses it. + await tagAdminClient.trail(trailId).tags().add("legal").withGasBudget(TEST_GAS_BUDGET).buildAndExecute( + tagAdminClient, + ); - let onChain = await admin.trail(trailId).get(); - console.log("Registry after adding \"legal\":", onChain.tags.map((t) => t.tag), "\n"); - assert.ok(onChain.tags.some((t) => t.tag === "finance")); - assert.ok(onChain.tags.some((t) => t.tag === "legal")); + let onChainTrail = await adminClient.trail(trailId).get(); + console.log("Registry after adding \"legal\":", onChainTrail.tags.map((t) => t.tag), "\n"); + assert.ok(onChainTrail.tags.some((t) => t.tag === "finance")); + assert.ok(onChainTrail.tags.some((t) => t.tag === "legal")); - // Create a role scoped to "finance" tag and issue to financeWriter. - await admin + // Create a role scoped to the "finance" tag and issue to financeWriterClient. + await adminClient .trail(trailId) .access() .forRole("FinanceWriter") .create(PermissionSet.recordAdminPermissions(), new RoleTags(["finance"])) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(admin); - await admin + .buildAndExecute(adminClient); + await adminClient .trail(trailId) .access() .forRole("FinanceWriter") - .issueCapability(new CapabilityIssueOptions(financeWriter.senderAddress())) + .issueCapability(new CapabilityIssueOptions(financeWriterClient.senderAddress())) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(admin); + .buildAndExecute(adminClient); // FinanceWriter adds a record using the "finance" tag. - await financeWriter + await financeWriterClient .trail(trailId) .records() .add(Data.fromString("Tagged finance entry"), undefined, "finance") .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(financeWriter); + .buildAndExecute(financeWriterClient); // TagAdmin attempts to remove "finance" tag — should fail because it's in use. let removeFinanceSucceeded = false; try { - await tagAdmin.trail(trailId).tags().remove("finance").withGasBudget(TEST_GAS_BUDGET).buildAndExecute(tagAdmin); + await tagAdminClient.trail(trailId).tags().remove("finance").withGasBudget(TEST_GAS_BUDGET).buildAndExecute( + tagAdminClient, + ); removeFinanceSucceeded = true; } catch { // Expected @@ -93,12 +97,14 @@ export async function manageRecordTags(): Promise { assert.equal(removeFinanceSucceeded, false, "a tag referenced by a role or record must not be removable"); // TagAdmin removes "legal" tag — should succeed because nothing uses it. - await tagAdmin.trail(trailId).tags().remove("legal").withGasBudget(TEST_GAS_BUDGET).buildAndExecute(tagAdmin); + await tagAdminClient.trail(trailId).tags().remove("legal").withGasBudget(TEST_GAS_BUDGET).buildAndExecute( + tagAdminClient, + ); - onChain = await admin.trail(trailId).get(); - console.log("Registry after removing \"legal\":", onChain.tags.map((t) => t.tag), "\n"); - assert.ok(onChain.tags.some((t) => t.tag === "finance"), "finance tag should still exist"); - assert.ok(!onChain.tags.some((t) => t.tag === "legal"), "legal tag should be removed"); + onChainTrail = await adminClient.trail(trailId).get(); + console.log("Registry after removing \"legal\":", onChainTrail.tags.map((t) => t.tag), "\n"); + assert.ok(onChainTrail.tags.some((t) => t.tag === "finance"), "finance tag should still exist"); + assert.ok(!onChainTrail.tags.some((t) => t.tag === "legal"), "legal tag should be removed"); console.log("Tag management completed successfully."); } 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 c6286734..da0dde79 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 @@ -8,7 +8,7 @@ * * ## Actors * - * - **Admin**: Creates the trail and sets up all roles and capabilities. + * - **Admin client**: Creates the trail and sets up all roles and capabilities. * - **DocsOperator**: Handles document submission (invoices, packing lists). Writes only * `documents`-tagged records. * - **ExportBroker**: Files export declarations and records clearance decisions at the origin. @@ -19,7 +19,7 @@ * `inspection`-tagged records; the role is created mid-process when an inspection is triggered. * - **Supervisor**: Updates the mutable trail metadata (processing status). No record-write * permissions. - * - **LockingAdmin**: Freezes the trail once the shipment is fully cleared. + * - **Locking admin client**: Freezes the trail once the shipment is fully cleared. * * ## How the trail is used * @@ -44,19 +44,19 @@ import { getFundedClient, issueTaggedRecordRole, TEST_GAS_BUDGET } from "../util export async function customsClearance(): Promise { console.log("=== Customs Clearance ===\n"); - const admin = await getFundedClient(); + const adminClient = await getFundedClient(); const docsOperator = await getFundedClient(); const exportBroker = await getFundedClient(); const importBroker = await getFundedClient(); const supervisor = await getFundedClient(); - const lockingAdmin = await getFundedClient(); + const lockingAdminClient = await getFundedClient(); const inspector = await getFundedClient(); // === Create the customs-clearance trail === console.log("Creating a customs-clearance trail..."); - const { output: created } = await admin + const { output: createdTrail } = await adminClient .createTrail() .withRecordTags(["documents", "export", "import", "inspection"]) .withTrailMetadata( @@ -70,47 +70,47 @@ export async function customsClearance(): Promise { .withInitialRecordString("Customs clearance case opened for inbound shipment", "event:case_opened", "documents") .finish() .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(admin); + .buildAndExecute(adminClient); - const trailId = created.id; + const trailId = createdTrail.id; // === Set up roles and capabilities for each actor === - await issueTaggedRecordRole(admin, trailId, "DocsOperator", "documents", docsOperator.senderAddress()); - await issueTaggedRecordRole(admin, trailId, "ExportBroker", "export", exportBroker.senderAddress()); - await issueTaggedRecordRole(admin, trailId, "ImportBroker", "import", importBroker.senderAddress()); + await issueTaggedRecordRole(adminClient, trailId, "DocsOperator", "documents", docsOperator.senderAddress()); + await issueTaggedRecordRole(adminClient, trailId, "ExportBroker", "export", exportBroker.senderAddress()); + await issueTaggedRecordRole(adminClient, trailId, "ImportBroker", "import", importBroker.senderAddress()); // Supervisor can update metadata. - await admin + await adminClient .trail(trailId) .access() .forRole("Supervisor") .create(PermissionSet.metadataAdminPermissions()) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(admin); - await admin + .buildAndExecute(adminClient); + await adminClient .trail(trailId) .access() .forRole("Supervisor") .issueCapability(new CapabilityIssueOptions(supervisor.senderAddress())) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(admin); + .buildAndExecute(adminClient); // LockingAdmin can manage locking. - await admin + await adminClient .trail(trailId) .access() .forRole("LockingAdmin") .create(PermissionSet.lockingAdminPermissions()) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(admin); - await admin + .buildAndExecute(adminClient); + await adminClient .trail(trailId) .access() .forRole("LockingAdmin") - .issueCapability(new CapabilityIssueOptions(lockingAdmin.senderAddress())) + .issueCapability(new CapabilityIssueOptions(lockingAdminClient.senderAddress())) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(admin); + .buildAndExecute(adminClient); // === Document submission === @@ -193,7 +193,7 @@ export async function customsClearance(): Promise { console.log("Inspection write was correctly denied before the inspector role existed.\n"); // A customs inspection is triggered; the inspector role is created and issued mid-process. - await issueTaggedRecordRole(admin, trailId, "Inspector", "inspection", inspector.senderAddress()); + await issueTaggedRecordRole(adminClient, trailId, "Inspector", "inspection", inspector.senderAddress()); const inspectionDone = await inspector .trail(trailId) @@ -238,15 +238,15 @@ export async function customsClearance(): Promise { // === Final lock and verification === - await lockingAdmin + await lockingAdminClient .trail(trailId) .locking() .updateWriteLock(TimeLock.withInfinite()) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(lockingAdmin); + .buildAndExecute(lockingAdminClient); - const afterLock = await admin.trail(trailId).get(); - console.log("Write lock after clearance:", afterLock.lockingConfig.writeLock, "\n"); + const trailAfterLock = await adminClient.trail(trailId).get(); + console.log("Write lock after clearance:", trailAfterLock.lockingConfig.writeLock, "\n"); let lateWriteSucceeded = false; try { @@ -262,16 +262,20 @@ export async function customsClearance(): Promise { } assert.equal(lateWriteSucceeded, false, "cleared customs trail should reject late writes after the final lock"); - const firstPage = await admin.trail(trailId).records().listPage(undefined, 20); + const firstRecordsPage = await adminClient.trail(trailId).records().listPage(undefined, 20); console.log("Recorded customs events:"); - for (const record of firstPage.records) { + for (const record of firstRecordsPage.records) { console.log(` #${record.sequenceNumber} | ${record.data} | tag=${record.tag} | ${record.metadata}`); } - assert.equal(firstPage.records.length, 7, "expected 7 customs records including the initial case-opened record"); + assert.equal( + firstRecordsPage.records.length, + 7, + "expected 7 customs records including the initial case-opened record", + ); - const trailState = await admin.trail(trailId).get(); - assert.equal(trailState.updatableMetadata, "Status: Cleared", "customs case should finish in cleared state"); + const finalTrail = await adminClient.trail(trailId).get(); + assert.equal(finalTrail.updatableMetadata, "Status: Cleared", "customs case should finish in cleared state"); console.log("\nCustoms clearance completed successfully."); } 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 a4deea4b..39b2df4e 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 @@ -9,7 +9,7 @@ * * ## Actors * - * - **Admin**: Creates the trail and sets up all roles and capabilities. + * - **Admin client**: Creates the trail and sets up all roles and capabilities. * - **Enroller**: Writes enrollment events. Restricted to the `enrollment` tag. * - **SafetyOfficer**: Records adverse events and safety observations. Restricted to `safety`. * - **EfficacyReviewer**: Records treatment outcomes. Restricted to `efficacy`. @@ -47,7 +47,7 @@ import { getFundedClient, issueTaggedRecordRole, TEST_GAS_BUDGET } from "../util export async function clinicalTrial(): Promise { console.log("=== Clinical Trial Data Integrity ===\n"); - const admin = await getFundedClient(); + const adminClient = await getFundedClient(); const enroller = await getFundedClient(); const safetyOfficer = await getFundedClient(); const efficacyReviewer = await getFundedClient(); @@ -60,7 +60,7 @@ export async function clinicalTrial(): Promise { console.log("Creating the clinical-trial audit trail..."); - const { output: created } = await admin + const { output: createdTrail } = await adminClient .createTrail() .withRecordTags(["enrollment", "safety", "efficacy"]) .withTrailMetadata( @@ -78,56 +78,56 @@ export async function clinicalTrial(): Promise { ) .finish() .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(admin); + .buildAndExecute(adminClient); - const trailId = created.id; + const trailId = createdTrail.id; console.log("Trail created with ID", trailId, "\n"); // === Define roles with tag-scoped permissions === console.log("Defining study roles..."); - await issueTaggedRecordRole(admin, trailId, "Enroller", "enrollment", enroller.senderAddress()); - await issueTaggedRecordRole(admin, trailId, "SafetyOfficer", "safety", safetyOfficer.senderAddress()); - await issueTaggedRecordRole(admin, trailId, "EfficacyReviewer", "efficacy", efficacyReviewer.senderAddress()); + await issueTaggedRecordRole(adminClient, trailId, "Enroller", "enrollment", enroller.senderAddress()); + await issueTaggedRecordRole(adminClient, trailId, "SafetyOfficer", "safety", safetyOfficer.senderAddress()); + await issueTaggedRecordRole(adminClient, trailId, "EfficacyReviewer", "efficacy", efficacyReviewer.senderAddress()); // Monitor can update metadata (study phase) — valid for 90 days. - await admin + await adminClient .trail(trailId) .access() .forRole("Monitor") .create(PermissionSet.metadataAdminPermissions()) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(admin); + .buildAndExecute(adminClient); const nowMs = BigInt(Date.now()); const studyEndMs = nowMs + BigInt(90 * 24 * 60 * 60 * 1000); - await admin + await adminClient .trail(trailId) .access() .forRole("Monitor") .issueCapability(new CapabilityIssueOptions(monitor.senderAddress(), nowMs, studyEndMs)) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(admin); + .buildAndExecute(adminClient); console.log("Monitor capability issued (expires at timestamp", studyEndMs + ")\n"); // Data Safety Board can manage locking. - await admin + await adminClient .trail(trailId) .access() .forRole("DataSafetyBoard") .create(PermissionSet.lockingAdminPermissions()) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(admin); - await admin + .buildAndExecute(adminClient); + await adminClient .trail(trailId) .access() .forRole("DataSafetyBoard") .issueCapability(new CapabilityIssueOptions(dataSafetyBoard.senderAddress())) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(admin); + .buildAndExecute(adminClient); // === Enrollment phase === @@ -178,10 +178,10 @@ export async function clinicalTrial(): Promise { console.log("--- Mid-Study Amendment ---"); - await admin.trail(trailId).tags().add("pk").withGasBudget(TEST_GAS_BUDGET).buildAndExecute(admin); + await adminClient.trail(trailId).tags().add("pk").withGasBudget(TEST_GAS_BUDGET).buildAndExecute(adminClient); console.log("Added tag \"pk\" (pharmacokinetics) to the trail."); - await issueTaggedRecordRole(admin, trailId, "PkAnalyst", "pk", pkAnalyst.senderAddress()); + await issueTaggedRecordRole(adminClient, trailId, "PkAnalyst", "pk", pkAnalyst.senderAddress()); const pkRecord = await pkAnalyst .trail(trailId) @@ -226,8 +226,8 @@ export async function clinicalTrial(): Promise { .withGasBudget(TEST_GAS_BUDGET) .buildAndExecute(monitor); - const trail = await admin.trail(trailId).get(); - console.log("Study phase updated to:", trail.updatableMetadata, "\n"); + const trailAfterMetadataUpdate = await adminClient.trail(trailId).get(); + console.log("Study phase updated to:", trailAfterMetadataUpdate.updatableMetadata, "\n"); // === Data Safety Board locks the study dataset === @@ -252,10 +252,10 @@ export async function clinicalTrial(): Promise { .withGasBudget(TEST_GAS_BUDGET) .buildAndExecute(dataSafetyBoard); - const finalLocking = await admin.trail(trailId).get(); + const finalLockedTrail = await adminClient.trail(trailId).get(); console.log( "Delete-trail lock set to", - finalLocking.lockingConfig.deleteTrailLock.type, + finalLockedTrail.lockingConfig.deleteTrailLock.type, "— trail cannot be deleted.\n", ); @@ -266,25 +266,32 @@ export async function clinicalTrial(): Promise { // In production the regulator would use AuditTrailClientReadOnly (no signing key). // Here a funded client is used to keep the example self-contained. const regulatorHandle = regulator.trail(trailId); - const onChain = await regulatorHandle.get(); + const regulatorTrailView = await regulatorHandle.get(); - console.log("Protocol:", onChain.immutableMetadata); - console.log("Phase: ", onChain.updatableMetadata); - console.log("Roles: ", onChain.roles.roles.map((r) => r.name)); - console.log("Tags: ", onChain.tags.map((t) => t.tag)); + console.log("Protocol:", regulatorTrailView.immutableMetadata); + console.log("Phase: ", regulatorTrailView.updatableMetadata); + console.log("Roles: ", regulatorTrailView.roles.roles.map((r) => r.name)); + console.log("Tags: ", regulatorTrailView.tags.map((t) => t.tag)); - const firstPage = await regulatorHandle.records().listPage(undefined, 20); - console.log("\nVerified records (" + firstPage.records.length + " total):"); - for (const record of firstPage.records) { + const firstRecordsPage = await regulatorHandle.records().listPage(undefined, 20); + console.log("\nVerified records (" + firstRecordsPage.records.length + " total):"); + for (const record of firstRecordsPage.records) { console.log(` #${record.sequenceNumber} | tag=${record.tag} | ${record.metadata}`); } - assert.equal(firstPage.records.length, 5, "expected 5 records (initial + enrolled + safety + efficacy + pk)"); - assert.ok(onChain.tags.some((t) => t.tag === "pk"), "the 'pk' tag must exist after mid-study amendment"); - assert.equal(onChain.lockingConfig.deleteRecordWindow.type, LockingWindow.withCountBased(BigInt(3)).type); - assert.equal(onChain.lockingConfig.deleteTrailLock.type, TimeLock.withInfinite().type); - assert.equal(onChain.lockingConfig.writeLock.type, TimeLock.withUnlockAtMs(lockUntilMs).type); - assert.equal(onChain.updatableMetadata, "Phase: Data Review"); + assert.equal( + firstRecordsPage.records.length, + 5, + "expected 5 records (initial + enrolled + safety + efficacy + pk)", + ); + assert.ok(regulatorTrailView.tags.some((t) => t.tag === "pk"), "the 'pk' tag must exist after mid-study amendment"); + assert.equal( + regulatorTrailView.lockingConfig.deleteRecordWindow.type, + LockingWindow.withCountBased(BigInt(3)).type, + ); + assert.equal(regulatorTrailView.lockingConfig.deleteTrailLock.type, TimeLock.withInfinite().type); + assert.equal(regulatorTrailView.lockingConfig.writeLock.type, TimeLock.withUnlockAtMs(lockUntilMs).type); + assert.equal(regulatorTrailView.updatableMetadata, "Phase: Data Review"); console.log("\nClinical trial data-integrity verification completed successfully."); } 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 index 983e057a..87d62f02 100644 --- 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 @@ -71,7 +71,7 @@ export async function digitalProductPassport(): Promise { console.log("Creating the DPP trail for EcoBike's battery..."); - const { output: created } = await manufacturer + const { output: createdTrail } = await manufacturer .createTrail() .withRecordTags(["manufacturing", "logistics", "ownership", "maintenance", "recycling", "rewards"]) .withTrailMetadata("DPP: Pro 48V Battery", "Manufacturer: EcoBike | Serial: EB-48V-2024-001337") @@ -85,7 +85,7 @@ export async function digitalProductPassport(): Promise { .withGasBudget(TEST_GAS_BUDGET) .buildAndExecute(manufacturer); - const trailId = created.id; + const trailId = createdTrail.id; console.log("Trail created with ID", trailId, "\n"); // === Define DPP roles and issue capabilities === @@ -209,7 +209,7 @@ export async function digitalProductPassport(): Promise { const nowMs = BigInt(Date.now()); const technicianValidUntilMs = nowMs + BigInt(30 * 24 * 60 * 60 * 1000); - const issuedTechnicianCap = await manufacturer + const serviceTechnicianCapability = await manufacturer .trail(trailId) .access() .forRole("ServiceTechnician") @@ -221,7 +221,7 @@ export async function digitalProductPassport(): Promise { console.log( "Issued ServiceTechnician capability", - issuedTechnicianCap.output.capabilityId, + serviceTechnicianCapability.output.capabilityId, "(valid until", technicianValidUntilMs + ").\n", ); @@ -279,64 +279,64 @@ export async function digitalProductPassport(): Promise { console.log("Verifying the resulting DPP..."); - const onChain = await manufacturer.trail(trailId).get(); - const firstPage = await manufacturer.trail(trailId).records().listPage(undefined, 20); + const onChainTrail = await manufacturer.trail(trailId).get(); + const firstRecordsPage = await manufacturer.trail(trailId).records().listPage(undefined, 20); console.log("Recorded DPP events:"); - for (const record of firstPage.records) { + for (const record of firstRecordsPage.records) { console.log(` #${record.sequenceNumber} | tag=${record.tag} | metadata=${record.metadata}`); } assert.equal( - firstPage.records.length, + firstRecordsPage.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"), + onChainTrail.tags.some((t) => t.tag === "maintenance") + && onChainTrail.tags.some((t) => t.tag === "recycling") + && onChainTrail.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"), + onChainTrail.roles.roles.some((r) => r.name === "Manufacturer") + && onChainTrail.roles.roles.some((r) => r.name === "Distributor") + && onChainTrail.roles.roles.some((r) => r.name === "Consumer") + && onChainTrail.roles.roles.some((r) => r.name === "ServiceTechnician") + && onChainTrail.roles.roles.some((r) => r.name === "Recycler") + && onChainTrail.roles.roles.some((r) => r.name === "EPRO") + && onChainTrail.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"); + assert.equal(onChainTrail.updatableMetadata, "Lifecycle Stage: Maintained and Ready for Continued Use"); - const maintenanceRecord = firstPage.records.find((record) => record.metadata === "event:annual_maintenance"); + const maintenanceRecord = firstRecordsPage.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"); + const rewardRecord = firstRecordsPage.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, + adminClient: AuditTrailClient, trailId: string, roleName: string, issuedTo: string, ): Promise { - await admin + await adminClient .trail(trailId) .access() .forRole(roleName) .create(PermissionSet.metadataAdminPermissions()) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(admin); - await admin + .buildAndExecute(adminClient); + await adminClient .trail(trailId) .access() .forRole(roleName) .issueCapability(new CapabilityIssueOptions(issuedTo)) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(admin); + .buildAndExecute(adminClient); } diff --git a/bindings/wasm/audit_trail_wasm/examples/src/util.ts b/bindings/wasm/audit_trail_wasm/examples/src/util.ts index 524bcfc3..a7b4795c 100644 --- a/bindings/wasm/audit_trail_wasm/examples/src/util.ts +++ b/bindings/wasm/audit_trail_wasm/examples/src/util.ts @@ -83,7 +83,7 @@ export async function createTrailWithSeedRecord(client: AuditTrailClient) { } export async function grantSelfRecordPermissions(client: AuditTrailClient, trailId: string): Promise { - const role = client.trail(trailId).access().forRole("example-record-writer"); + const selfRecordWriterRole = client.trail(trailId).access().forRole("example-record-writer"); const permissions = new PermissionSet([ Permission.AddRecord, Permission.DeleteRecord, @@ -91,32 +91,32 @@ export async function grantSelfRecordPermissions(client: AuditTrailClient, trail Permission.CorrectRecord, ]); - await role.create(permissions).withGasBudget(TEST_GAS_BUDGET).buildAndExecute(client); - await role + await selfRecordWriterRole.create(permissions).withGasBudget(TEST_GAS_BUDGET).buildAndExecute(client); + await selfRecordWriterRole .issueCapability(new CapabilityIssueOptions(client.senderAddress())) .withGasBudget(TEST_GAS_BUDGET) .buildAndExecute(client); } export async function issueTaggedRecordRole( - admin: AuditTrailClient, + adminClient: AuditTrailClient, trailId: string, roleName: string, tag: string, - issuedTo: string, + issuedToAddress: string, ): Promise { - await admin + await adminClient .trail(trailId) .access() .forRole(roleName) .create(PermissionSet.recordAdminPermissions(), new RoleTags([tag])) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(admin); - await admin + .buildAndExecute(adminClient); + await adminClient .trail(trailId) .access() .forRole(roleName) - .issueCapability(new CapabilityIssueOptions(issuedTo)) + .issueCapability(new CapabilityIssueOptions(issuedToAddress)) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(admin); + .buildAndExecute(adminClient); } diff --git a/examples/audit-trail/01_create_audit_trail.rs b/examples/audit-trail/01_create_audit_trail.rs index b4e7c6d4..42a9422c 100644 --- a/examples/audit-trail/01_create_audit_trail.rs +++ b/examples/audit-trail/01_create_audit_trail.rs @@ -3,8 +3,8 @@ //! ## Actors //! -//! - **Admin**: Creates the trail and holds the built-in Admin capability that is automatically minted on creation. -//! - **RecordAdmin**: Receives a RecordAdmin capability bound to their address. Writes records in subsequent examples. +//! - **Admin client**: Creates the trail and holds the built-in Admin capability minted on creation. +//! - **Record admin client**: Receives a RecordAdmin capability bound to their address so it can write records. use anyhow::Result; use audit_trail::core::types::{CapabilityIssueOptions, Data, ImmutableMetadata, InitialRecord, PermissionSet}; @@ -20,13 +20,15 @@ use product_common::core_client::CoreClient; async fn main() -> Result<()> { println!("=== Audit Trail: Create Trail & Define Roles ===\n"); - // `admin` creates the trail and holds the Admin capability that is automatically - // minted on creation. `record_admin` represents the actor who will later write records. - let admin = get_funded_audit_trail_client().await?; - let record_admin = get_funded_audit_trail_client().await?; + // Use separate clients to show that admin rights and record-writing rights can belong to different addresses. + let admin_client = get_funded_audit_trail_client().await?; + let record_admin_client = get_funded_audit_trail_client().await?; - println!("Admin address: {}", admin.sender_address()); - println!("RecordAdmin address: {}\n", record_admin.sender_address()); + println!("Admin client address: {}", admin_client.sender_address()); + println!( + "Record admin client address: {}\n", + record_admin_client.sender_address() + ); // ------------------------------------------------------------------------- // Step 1: Create an audit trail @@ -39,7 +41,7 @@ async fn main() -> Result<()> { // object and transfers it to the sender's address. This capability grants // full administrative control over the trail (role management, capability // issuance, tag management, etc.). - let created = admin + let created_trail = admin_client .create_trail() .with_trail_metadata(ImmutableMetadata::new( "Product Shipment Audit Trail".to_string(), @@ -52,19 +54,19 @@ async fn main() -> Result<()> { None, )) .finish() - .build_and_execute(&admin) + .build_and_execute(&admin_client) .await? .output; println!( "Trail created!\n Trail ID: {}\n Creator: {}\n Timestamp: {} ms\n", - created.trail_id, created.creator, created.timestamp + created_trail.trail_id, created_trail.creator, created_trail.timestamp ); - // Fetch the on-chain trail object to inspect the automatically created Admin role. - let trail = admin.trail(created.trail_id).get().await?; - let admin_role_name = &trail.roles.initial_admin_role_name; - let admin_permissions = &trail.roles.roles[admin_role_name].permissions; + // Fetch the trail to inspect the role map that was initialized during creation. + let on_chain_trail = admin_client.trail(created_trail.trail_id).get().await?; + let admin_role_name = &on_chain_trail.roles.initial_admin_role_name; + let admin_permissions = &on_chain_trail.roles.roles[admin_role_name].permissions; println!( "Built-in admin role: \"{admin_role_name}\" ({} permissions)\n", admin_permissions.len() @@ -73,48 +75,47 @@ async fn main() -> Result<()> { // ------------------------------------------------------------------------- // Step 2: Define a RecordAdmin role // ------------------------------------------------------------------------- - // The Admin capability (held by the sender) allows creating new roles. - // PermissionSet::record_admin_permissions() grants AddRecord, DeleteRecord, - // and CorrectRecord permissions. + // The Admin capability in `admin_client`'s wallet authorizes this role-management transaction. + // This permission set is the standard bundle for adding, deleting, and correcting records. let record_admin_role = "RecordAdmin"; - let role_created = admin - .trail(created.trail_id) + let created_role = admin_client + .trail(created_trail.trail_id) .access() .for_role(record_admin_role) .create(PermissionSet::record_admin_permissions(), None) - .build_and_execute(&admin) + .build_and_execute(&admin_client) .await? .output; println!( "Role \"{}\" defined with permissions:\n {:?}\n", - role_created.role, role_created.permissions.permissions + created_role.role, created_role.permissions.permissions ); // ------------------------------------------------------------------------- // Step 3: Issue a capability for the RecordAdmin role // ------------------------------------------------------------------------- - // A Capability object is minted on-chain and transferred to `record_admin`'s - // address. Only the holder of that address can use it to write records. - let capability = admin - .trail(created.trail_id) + // Issuing the capability delegates this role to `record_admin_client`; the Admin capability stays with + // `admin_client`. + let record_admin_capability = admin_client + .trail(created_trail.trail_id) .access() .for_role(record_admin_role) .issue_capability(CapabilityIssueOptions { - issued_to: Some(record_admin.sender_address()), + issued_to: Some(record_admin_client.sender_address()), valid_from_ms: None, valid_until_ms: None, }) - .build_and_execute(&admin) + .build_and_execute(&admin_client) .await? .output; println!( "Capability issued!\n Capability ID: {}\n Trail ID: {}\n Role: {}\n Issued to: {}", - capability.capability_id, - capability.target_key, - capability.role, - capability + record_admin_capability.capability_id, + record_admin_capability.target_key, + record_admin_capability.role, + record_admin_capability .issued_to .map_or_else(|| "any holder (no address restriction)".to_string(), |a| a.to_string()) ); diff --git a/examples/audit-trail/02_add_and_read_records.rs b/examples/audit-trail/02_add_and_read_records.rs index 903edbc8..7b1f37e6 100644 --- a/examples/audit-trail/02_add_and_read_records.rs +++ b/examples/audit-trail/02_add_and_read_records.rs @@ -3,9 +3,9 @@ //! ## Actors //! -//! - **Admin**: Creates the trail, defines the RecordAdmin role, and issues a capability. -//! - **RecordAdmin**: Holds the capability and writes records. Reads are also done through this client to demonstrate -//! that any address can read, but only the cap holder can write. +//! - **Admin client**: Creates the trail, defines the RecordAdmin role, and issues a capability. +//! - **Record admin client**: Holds the capability and writes records. Reads are also done through this client to keep +//! the example focused on one trail handle after delegation. use anyhow::{Result, ensure}; use audit_trail::core::types::{CapabilityIssueOptions, Data, InitialRecord, PermissionSet}; @@ -21,18 +21,21 @@ use product_common::core_client::CoreClient; async fn main() -> Result<()> { println!("=== Audit Trail: Add & Read Records ===\n"); - // `admin` creates the trail and manages roles. - // `record_admin` holds the RecordAdmin capability and writes records. - let admin = get_funded_audit_trail_client().await?; - let record_admin = get_funded_audit_trail_client().await?; + // Use separate clients to make the permission handoff explicit. + let admin_client = get_funded_audit_trail_client().await?; + let record_admin_client = get_funded_audit_trail_client().await?; - println!("Admin address: {}", admin.sender_address()); - println!("RecordAdmin address: {}\n", record_admin.sender_address()); + println!("Admin client address: {}", admin_client.sender_address()); + println!( + "Record admin client address: {}\n", + record_admin_client.sender_address() + ); // ------------------------------------------------------------------------- // Step 1: Create a trail with one initial record // ------------------------------------------------------------------------- - let created = admin + // Creating the trail automatically gives `admin_client` the built-in Admin capability. + let created_trail = admin_client .create_trail() .with_initial_record(InitialRecord::new( Data::text("Trail opened"), @@ -40,47 +43,49 @@ async fn main() -> Result<()> { None, )) .finish() - .build_and_execute(&admin) + .build_and_execute(&admin_client) .await? .output; - let trail_id = created.trail_id; + let trail_id = created_trail.trail_id; println!("Trail created: {trail_id}\n"); // ------------------------------------------------------------------------- - // Step 2: Create a record-admin role and issue a capability for it + // Step 2: Create a RecordAdmin role and issue a capability for it // ------------------------------------------------------------------------- - admin + // The role defines what record operations are allowed. + admin_client .trail(trail_id) .access() .for_role("RecordAdmin") .create(PermissionSet::record_admin_permissions(), None) - .build_and_execute(&admin) + .build_and_execute(&admin_client) .await?; - let capability = admin + // The capability grants that role to `record_admin_client`'s address. + let record_admin_capability = admin_client .trail(trail_id) .access() .for_role("RecordAdmin") .issue_capability(CapabilityIssueOptions { - issued_to: Some(record_admin.sender_address()), + issued_to: Some(record_admin_client.sender_address()), valid_from_ms: None, valid_until_ms: None, }) - .build_and_execute(&admin) + .build_and_execute(&admin_client) .await? .output; println!( "Issued capability {} for role {}\n", - capability.capability_id, capability.role + record_admin_capability.capability_id, record_admin_capability.role ); // ------------------------------------------------------------------------- // Step 3: Append follow-up records // ------------------------------------------------------------------------- - // The client automatically finds the capability in `record_admin`'s wallet. - let records = record_admin.trail(trail_id).records(); + // The record API automatically selects the matching capability from `record_admin_client`'s wallet. + let records = record_admin_client.trail(trail_id).records(); let first_added = records .add( @@ -88,7 +93,7 @@ async fn main() -> Result<()> { Some("event:received".to_string()), None, ) - .build_and_execute(&record_admin) + .build_and_execute(&record_admin_client) .await? .output; @@ -98,7 +103,7 @@ async fn main() -> Result<()> { Some("event:dispatched".to_string()), None, ) - .build_and_execute(&record_admin) + .build_and_execute(&record_admin_client) .await? .output; @@ -110,6 +115,7 @@ async fn main() -> Result<()> { // ------------------------------------------------------------------------- // Step 4: Read records back by sequence number // ------------------------------------------------------------------------- + // Sequence numbers start at 0, so the initial record is still addressable after appending more records. let initial = records.get(0).await?; let first = records.get(first_added.sequence_number).await?; let second = records.get(second_added.sequence_number).await?; @@ -131,6 +137,7 @@ async fn main() -> Result<()> { // ------------------------------------------------------------------------- // Step 5: Inspect record count and page through the linked table // ------------------------------------------------------------------------- + // Pagination keeps reads bounded for trails that grow over time. let count = records.record_count().await?; println!("Current record count: {count}"); ensure!(count == 3, "expected 3 records, got {count}"); diff --git a/examples/audit-trail/03_update_metadata.rs b/examples/audit-trail/03_update_metadata.rs index 94eac179..8ef72075 100644 --- a/examples/audit-trail/03_update_metadata.rs +++ b/examples/audit-trail/03_update_metadata.rs @@ -3,8 +3,8 @@ //! ## Actors //! -//! - **Admin**: Creates the trail and sets up the MetadataAdmin role. -//! - **MetadataAdmin**: Holds the MetadataAdmin capability and updates the trail's mutable status field. Has no +//! - **Admin client**: Creates the trail and sets up the MetadataAdmin role. +//! - **Metadata admin client**: Holds the MetadataAdmin capability and updates the trail's mutable status field. Has no //! record-write permissions. use anyhow::{Result, ensure}; @@ -21,17 +21,16 @@ use product_common::core_client::CoreClient; async fn main() -> Result<()> { println!("=== Audit Trail: Update Metadata ===\n"); - // `admin` creates the trail and manages roles. - // `metadata_admin` holds the MetadataAdmin capability and updates the trail status. - let admin = get_funded_audit_trail_client().await?; - let metadata_admin = get_funded_audit_trail_client().await?; + // Use separate clients so metadata updates are clearly delegated away from the creator. + let admin_client = get_funded_audit_trail_client().await?; + let metadata_admin_client = get_funded_audit_trail_client().await?; let immutable_metadata = ImmutableMetadata::new( "Shipment Processing".to_string(), Some("Tracks the lifecycle of a warehouse shipment".to_string()), ); - let created = admin + let created_trail = admin_client .create_trail() .with_trail_metadata(immutable_metadata.clone()) .with_updatable_metadata("Status: Draft") @@ -41,67 +40,69 @@ async fn main() -> Result<()> { None, )) .finish() - .build_and_execute(&admin) + .build_and_execute(&admin_client) .await? .output; - let trail_id = created.trail_id; + let trail_id = created_trail.trail_id; + let metadata_admin_role = "MetadataAdmin"; - admin + // The Admin capability in `admin_client`'s wallet authorizes role definition and capability issuance. + admin_client .trail(trail_id) .access() - .for_role("MetadataAdmin") + .for_role(metadata_admin_role) .create(PermissionSet::metadata_admin_permissions(), None) - .build_and_execute(&admin) + .build_and_execute(&admin_client) .await?; - admin + admin_client .trail(trail_id) .access() - .for_role("MetadataAdmin") + .for_role(metadata_admin_role) .issue_capability(CapabilityIssueOptions { - issued_to: Some(metadata_admin.sender_address()), + issued_to: Some(metadata_admin_client.sender_address()), valid_from_ms: None, valid_until_ms: None, }) - .build_and_execute(&admin) + .build_and_execute(&admin_client) .await?; - let before = admin.trail(trail_id).get().await?; + let trail_before_update = admin_client.trail(trail_id).get().await?; println!( "Before update:\n immutable = {:?}\n updatable = {:?}\n", - before.immutable_metadata, before.updatable_metadata + trail_before_update.immutable_metadata, trail_before_update.updatable_metadata ); - metadata_admin + metadata_admin_client .trail(trail_id) .update_metadata(Some("Status: In Review".to_string())) - .build_and_execute(&metadata_admin) + .build_and_execute(&metadata_admin_client) .await?; - let after_update = admin.trail(trail_id).get().await?; + let trail_after_update = admin_client.trail(trail_id).get().await?; println!( "After update:\n immutable = {:?}\n updatable = {:?}\n", - after_update.immutable_metadata, after_update.updatable_metadata + trail_after_update.immutable_metadata, trail_after_update.updatable_metadata ); - ensure!(after_update.immutable_metadata == Some(immutable_metadata.clone())); - ensure!(after_update.updatable_metadata.as_deref() == Some("Status: In Review")); + ensure!(trail_after_update.immutable_metadata == Some(immutable_metadata.clone())); + ensure!(trail_after_update.updatable_metadata.as_deref() == Some("Status: In Review")); - metadata_admin + metadata_admin_client .trail(trail_id) .update_metadata(None) - .build_and_execute(&metadata_admin) + .build_and_execute(&metadata_admin_client) .await?; - let after_clear = admin.trail(trail_id).get().await?; + let trail_after_clear = admin_client.trail(trail_id).get().await?; println!( "After clear:\n immutable = {:?}\n updatable = {:?}", - after_clear.immutable_metadata, after_clear.updatable_metadata + trail_after_clear.immutable_metadata, trail_after_clear.updatable_metadata ); - ensure!(after_clear.immutable_metadata == Some(immutable_metadata)); - ensure!(after_clear.updatable_metadata.is_none()); + ensure!(trail_after_clear.immutable_metadata == Some(immutable_metadata)); + ensure!(trail_after_clear.updatable_metadata.is_none()); Ok(()) } diff --git a/examples/audit-trail/04_configure_locking.rs b/examples/audit-trail/04_configure_locking.rs index c9c71caa..173a37db 100644 --- a/examples/audit-trail/04_configure_locking.rs +++ b/examples/audit-trail/04_configure_locking.rs @@ -3,10 +3,10 @@ //! ## Actors //! -//! - **Admin**: Creates the trail and sets up the LockingAdmin and RecordAdmin roles. -//! - **LockingAdmin**: Controls write and delete locks. Holds the LockingAdmin capability. -//! - **RecordAdmin**: Writes records. Used to demonstrate that the write lock is enforced per-sender, not just checked -//! by the admin. +//! - **Admin client**: Creates the trail and sets up the LockingAdmin and RecordAdmin roles. +//! - **Locking admin client**: Controls write and delete locks. Holds the LockingAdmin capability. +//! - **Record admin client**: Writes records. Used to demonstrate that the write lock is enforced per-sender, not just +//! checked by the admin. use anyhow::{Result, ensure}; use audit_trail::core::types::{CapabilityIssueOptions, Data, InitialRecord, LockingWindow, PermissionSet, TimeLock}; @@ -22,14 +22,12 @@ use product_common::core_client::CoreClient; async fn main() -> Result<()> { println!("=== Audit Trail: Configure Locking ===\n"); - // `admin` creates the trail and manages roles. - // `locking_admin` controls write and delete locks. - // `record_admin` writes records. - let admin = get_funded_audit_trail_client().await?; - let locking_admin = get_funded_audit_trail_client().await?; - let record_admin = get_funded_audit_trail_client().await?; + // Use separate clients to show that locking and record-writing permissions can be delegated independently. + let admin_client = get_funded_audit_trail_client().await?; + let locking_admin_client = get_funded_audit_trail_client().await?; + let record_admin_client = get_funded_audit_trail_client().await?; - let created = admin + let created_trail = admin_client .create_trail() .with_initial_record(InitialRecord::new( Data::text("Trail opened"), @@ -37,113 +35,119 @@ async fn main() -> Result<()> { None, )) .finish() - .build_and_execute(&admin) + .build_and_execute(&admin_client) .await? .output; - let trail_id = created.trail_id; + let trail_id = created_trail.trail_id; + let locking_admin_role = "LockingAdmin"; + let record_admin_role = "RecordAdmin"; - admin + // The Admin capability authorizes defining roles and issuing the delegated capabilities. + admin_client .trail(trail_id) .access() - .for_role("LockingAdmin") + .for_role(locking_admin_role) .create(PermissionSet::locking_admin_permissions(), None) - .build_and_execute(&admin) + .build_and_execute(&admin_client) .await?; - admin + admin_client .trail(trail_id) .access() - .for_role("LockingAdmin") + .for_role(locking_admin_role) .issue_capability(CapabilityIssueOptions { - issued_to: Some(locking_admin.sender_address()), + issued_to: Some(locking_admin_client.sender_address()), valid_from_ms: None, valid_until_ms: None, }) - .build_and_execute(&admin) + .build_and_execute(&admin_client) .await?; - admin + admin_client .trail(trail_id) .access() - .for_role("RecordAdmin") + .for_role(record_admin_role) .create(PermissionSet::record_admin_permissions(), None) - .build_and_execute(&admin) + .build_and_execute(&admin_client) .await?; - admin + admin_client .trail(trail_id) .access() - .for_role("RecordAdmin") + .for_role(record_admin_role) .issue_capability(CapabilityIssueOptions { - issued_to: Some(record_admin.sender_address()), + issued_to: Some(record_admin_client.sender_address()), valid_from_ms: None, valid_until_ms: None, }) - .build_and_execute(&admin) + .build_and_execute(&admin_client) .await?; - locking_admin + locking_admin_client .trail(trail_id) .locking() .update_write_lock(TimeLock::Infinite) - .build_and_execute(&locking_admin) + .build_and_execute(&locking_admin_client) .await?; - let locked = admin.trail(trail_id).get().await?; - println!("Write lock after update: {:?}\n", locked.locking_config.write_lock); - ensure!(locked.locking_config.write_lock == TimeLock::Infinite); + let locked_trail = admin_client.trail(trail_id).get().await?; + println!( + "Write lock after update: {:?}\n", + locked_trail.locking_config.write_lock + ); + ensure!(locked_trail.locking_config.write_lock == TimeLock::Infinite); - let blocked_add = record_admin + let blocked_add = record_admin_client .trail(trail_id) .records() .add(Data::text("This write should fail"), None, None) - .build_and_execute(&record_admin) + .build_and_execute(&record_admin_client) .await; ensure!(blocked_add.is_err(), "write lock should block adding records"); - locking_admin + locking_admin_client .trail(trail_id) .locking() .update_write_lock(TimeLock::None) - .build_and_execute(&locking_admin) + .build_and_execute(&locking_admin_client) .await?; - let added = record_admin + let added_record = record_admin_client .trail(trail_id) .records() .add(Data::text("Write lock lifted"), Some("event:resumed".to_string()), None) - .build_and_execute(&record_admin) + .build_and_execute(&record_admin_client) .await? .output; println!( "Added record {} after clearing the write lock.\n", - added.sequence_number + added_record.sequence_number ); - locking_admin + locking_admin_client .trail(trail_id) .locking() .update_delete_record_window(LockingWindow::CountBased { count: 2 }) - .build_and_execute(&locking_admin) + .build_and_execute(&locking_admin_client) .await?; - locking_admin + locking_admin_client .trail(trail_id) .locking() .update_delete_trail_lock(TimeLock::Infinite) - .build_and_execute(&locking_admin) + .build_and_execute(&locking_admin_client) .await?; - let final_state = admin.trail(trail_id).get().await?; + let final_trail = admin_client.trail(trail_id).get().await?; println!( "Final locking config:\n delete_record_window = {:?}\n delete_trail_lock = {:?}\n write_lock = {:?}", - final_state.locking_config.delete_record_window, - final_state.locking_config.delete_trail_lock, - final_state.locking_config.write_lock + final_trail.locking_config.delete_record_window, + final_trail.locking_config.delete_trail_lock, + final_trail.locking_config.write_lock ); - ensure!(final_state.locking_config.delete_record_window == LockingWindow::CountBased { count: 2 }); - ensure!(final_state.locking_config.delete_trail_lock == TimeLock::Infinite); - ensure!(final_state.locking_config.write_lock == TimeLock::None); + ensure!(final_trail.locking_config.delete_record_window == LockingWindow::CountBased { count: 2 }); + ensure!(final_trail.locking_config.delete_trail_lock == TimeLock::Infinite); + ensure!(final_trail.locking_config.write_lock == TimeLock::None); Ok(()) } diff --git a/examples/audit-trail/05_manage_access.rs b/examples/audit-trail/05_manage_access.rs index 828db2d4..90b087d2 100644 --- a/examples/audit-trail/05_manage_access.rs +++ b/examples/audit-trail/05_manage_access.rs @@ -3,10 +3,10 @@ //! ## Actors //! -//! - **Admin**: Creates and updates roles, issues capabilities, revokes and destroys them, and finally deletes the role -//! once it is no longer needed. -//! - **OperationsUser**: The subject of all capability issuance. Capabilities are bound to this address to demonstrate -//! that revocation immediately blocks their access. +//! - **Admin client**: Creates and updates roles, issues capabilities, revokes and destroys them, and finally deletes +//! the role once it is no longer needed. +//! - **Operations user client**: The subject of all capability issuance. Capabilities are bound to this address to +//! demonstrate that revocation immediately blocks their access. use std::collections::HashSet; @@ -24,12 +24,11 @@ use product_common::core_client::CoreClient; async fn main() -> Result<()> { println!("=== Audit Trail: Manage Access ===\n"); - // `admin` manages roles and capability lifecycle. - // `operations_user` represents the actor who receives (and later loses) access. - let admin = get_funded_audit_trail_client().await?; - let operations_user = get_funded_audit_trail_client().await?; + // Use a separate operations client so capability ownership and revocation are visible. + let admin_client = get_funded_audit_trail_client().await?; + let operations_user_client = get_funded_audit_trail_client().await?; - let created = admin + let created_trail = admin_client .create_trail() .with_initial_record(audit_trail::core::types::InitialRecord::new( Data::text("Trail created"), @@ -37,21 +36,23 @@ async fn main() -> Result<()> { None, )) .finish() - .build_and_execute(&admin) + .build_and_execute(&admin_client) .await? .output; - let trail_id = created.trail_id; + let trail_id = created_trail.trail_id; + let operations_role = "Operations"; - let created_role = admin + // The Admin capability authorizes the custom role definition. + let created_operations_role = admin_client .trail(trail_id) .access() - .for_role("Operations") + .for_role(operations_role) .create(PermissionSet::record_admin_permissions(), None) - .build_and_execute(&admin) + .build_and_execute(&admin_client) .await? .output; - println!("Created role: {}\n", created_role.role); + println!("Created role: {}\n", created_operations_role.role); let updated_permissions = PermissionSet { permissions: HashSet::from([ @@ -61,88 +62,98 @@ async fn main() -> Result<()> { ]), }; - let updated_role = admin + let updated_operations_role = admin_client .trail(trail_id) .access() - .for_role("Operations") + .for_role(operations_role) .update_permissions(updated_permissions.clone(), None) - .build_and_execute(&admin) + .build_and_execute(&admin_client) .await? .output; - println!("Updated role permissions: {:?}\n", updated_role.permissions.permissions); + println!( + "Updated role permissions: {:?}\n", + updated_operations_role.permissions.permissions + ); - let constrained_capability = admin + let operations_capability = admin_client .trail(trail_id) .access() - .for_role("Operations") + .for_role(operations_role) .issue_capability(CapabilityIssueOptions { - issued_to: Some(operations_user.sender_address()), + issued_to: Some(operations_user_client.sender_address()), valid_from_ms: None, valid_until_ms: Some(4_102_444_800_000), }) - .build_and_execute(&admin) + .build_and_execute(&admin_client) .await? .output; println!( "Issued constrained capability:\n id = {}\n issued_to = {:?}\n valid_until = {:?}\n", - constrained_capability.capability_id, constrained_capability.issued_to, constrained_capability.valid_until + operations_capability.capability_id, operations_capability.issued_to, operations_capability.valid_until ); - let on_chain = admin.trail(trail_id).get().await?; - let role_definition = on_chain.roles.roles.get("Operations").expect("role must exist"); - ensure!(role_definition.permissions == updated_permissions.permissions); + let on_chain_trail = admin_client.trail(trail_id).get().await?; + let operations_role_definition = on_chain_trail + .roles + .roles + .get(operations_role) + .expect("role must exist"); + ensure!(operations_role_definition.permissions == updated_permissions.permissions); - admin + admin_client .trail(trail_id) .access() - .revoke_capability(constrained_capability.capability_id, constrained_capability.valid_until) - .build_and_execute(&admin) + .revoke_capability(operations_capability.capability_id, operations_capability.valid_until) + .build_and_execute(&admin_client) .await?; - println!("Revoked capability {}\n", constrained_capability.capability_id); + println!("Revoked capability {}\n", operations_capability.capability_id); // destroy_capability consumes the capability object, so the signer must own it. - // The capability is issued to admin so admin can destroy it directly. - let disposable_capability = admin + // This disposable capability is issued back to `admin_client` so it can be destroyed directly. + let disposable_operations_capability = admin_client .trail(trail_id) .access() - .for_role("Operations") + .for_role(operations_role) .issue_capability(CapabilityIssueOptions { - issued_to: Some(admin.sender_address()), + issued_to: Some(admin_client.sender_address()), valid_from_ms: None, valid_until_ms: None, }) - .build_and_execute(&admin) + .build_and_execute(&admin_client) .await? .output; - admin + admin_client .trail(trail_id) .access() - .destroy_capability(disposable_capability.capability_id) - .build_and_execute(&admin) + .destroy_capability(disposable_operations_capability.capability_id) + .build_and_execute(&admin_client) .await?; - println!("Destroyed capability {}\n", disposable_capability.capability_id); + println!( + "Destroyed capability {}\n", + disposable_operations_capability.capability_id + ); - admin + admin_client .trail(trail_id) .access() .cleanup_revoked_capabilities() - .build_and_execute(&admin) + .build_and_execute(&admin_client) .await?; println!("Cleaned up revoked capability registry entries.\n"); - admin + admin_client .trail(trail_id) .access() - .for_role("Operations") + .for_role(operations_role) .delete() - .build_and_execute(&admin) + .build_and_execute(&admin_client) .await?; - let after_delete = admin.trail(trail_id).get().await?; + let trail_after_role_delete = admin_client.trail(trail_id).get().await?; ensure!( - !after_delete.roles.roles.contains_key("Operations"), + !trail_after_role_delete.roles.roles.contains_key(operations_role), "role should be removed from the trail" ); diff --git a/examples/audit-trail/06_delete_records.rs b/examples/audit-trail/06_delete_records.rs index c92f409f..9f4f8194 100644 --- a/examples/audit-trail/06_delete_records.rs +++ b/examples/audit-trail/06_delete_records.rs @@ -3,9 +3,9 @@ //! ## Actors //! -//! - **Admin**: Creates the trail and sets up the RecordMaintenance role. -//! - **RecordMaintainer**: Holds the RecordMaintenance capability. Adds records and then deletes them individually and -//! in batch. +//! - **Admin client**: Creates the trail and sets up the RecordMaintenance role. +//! - **Maintenance admin client**: Holds the RecordMaintenance capability. Adds records and then deletes them +//! individually and in batch. use std::collections::HashSet; @@ -22,12 +22,11 @@ use product_common::core_client::CoreClient; async fn main() -> Result<()> { println!("=== Audit Trail: Delete Records ===\n"); - // `admin` creates the trail and manages roles. - // `record_maintainer` adds and deletes records. - let admin = get_funded_audit_trail_client().await?; - let record_maintainer = get_funded_audit_trail_client().await?; + // Use a maintenance client to show deletes happening through a delegated capability. + let admin_client = get_funded_audit_trail_client().await?; + let maintenance_admin_client = get_funded_audit_trail_client().await?; - let created = admin + let created_trail = admin_client .create_trail() .with_initial_record(InitialRecord::new( Data::text("Initial record"), @@ -35,15 +34,18 @@ async fn main() -> Result<()> { None, )) .finish() - .build_and_execute(&admin) + .build_and_execute(&admin_client) .await? .output; - let trail = admin.trail(created.trail_id); + let trail_id = created_trail.trail_id; + let maintenance_admin_role = "RecordMaintenance"; + let admin_trail = admin_client.trail(trail_id); - trail + // This role grants both single-record and batch-delete permissions. + admin_trail .access() - .for_role("RecordMaintenance") + .for_role(maintenance_admin_role) .create( PermissionSet { permissions: HashSet::from([ @@ -54,61 +56,65 @@ async fn main() -> Result<()> { }, None, ) - .build_and_execute(&admin) + .build_and_execute(&admin_client) .await?; - trail + admin_trail .access() - .for_role("RecordMaintenance") + .for_role(maintenance_admin_role) .issue_capability(CapabilityIssueOptions { - issued_to: Some(record_maintainer.sender_address()), + issued_to: Some(maintenance_admin_client.sender_address()), valid_from_ms: None, valid_until_ms: None, }) - .build_and_execute(&admin) + .build_and_execute(&admin_client) .await?; - let records = record_maintainer.trail(created.trail_id).records(); + let maintenance_records = maintenance_admin_client.trail(trail_id).records(); - let added_one = records + let first_added_record = maintenance_records .add(Data::text("Second record"), Some("event:received".to_string()), None) - .build_and_execute(&record_maintainer) + .build_and_execute(&maintenance_admin_client) .await? .output; - let added_two = records + let second_added_record = maintenance_records .add(Data::text("Third record"), Some("event:dispatched".to_string()), None) - .build_and_execute(&record_maintainer) + .build_and_execute(&maintenance_admin_client) .await? .output; println!( "Trail has records at sequence numbers 0, {}, {}\n", - added_one.sequence_number, added_two.sequence_number + first_added_record.sequence_number, second_added_record.sequence_number ); - ensure!(records.record_count().await? == 3); + ensure!(maintenance_records.record_count().await? == 3); - let deleted_one = records - .delete(added_one.sequence_number) - .build_and_execute(&record_maintainer) + let deleted_record = maintenance_records + .delete(first_added_record.sequence_number) + .build_and_execute(&maintenance_admin_client) .await? .output; - println!("Deleted record {}\n", deleted_one.sequence_number); + println!("Deleted record {}\n", deleted_record.sequence_number); - ensure!(records.record_count().await? == 2); + ensure!(maintenance_records.record_count().await? == 2); ensure!( - records.get(added_one.sequence_number).await.is_err(), + maintenance_records + .get(first_added_record.sequence_number) + .await + .is_err(), "deleted record should no longer be readable" ); - let deleted_remaining = records + // Batch delete returns the exact sequence numbers that were removed. + let deleted_sequence_numbers = maintenance_records .delete_records_batch(10) - .build_and_execute(&record_maintainer) + .build_and_execute(&maintenance_admin_client) .await? .output; - println!("Batch deleted the remaining {deleted_remaining} records."); - ensure!(deleted_remaining == 2); - ensure!(records.record_count().await? == 0); + println!("Batch deleted the remaining records: {deleted_sequence_numbers:?}."); + ensure!(deleted_sequence_numbers == vec![0, second_added_record.sequence_number]); + ensure!(maintenance_records.record_count().await? == 0); Ok(()) } diff --git a/examples/audit-trail/07_access_read_only_methods.rs b/examples/audit-trail/07_access_read_only_methods.rs index 19151284..02872b66 100644 --- a/examples/audit-trail/07_access_read_only_methods.rs +++ b/examples/audit-trail/07_access_read_only_methods.rs @@ -3,9 +3,9 @@ //! ## Actors //! -//! - **Admin**: Creates the trail and sets up the RecordAdmin role. -//! - **RecordAdmin**: Adds one follow-up record. All subsequent operations are read-only and can be performed by any -//! address — no capability required. +//! - **Admin client**: Creates the trail and sets up the RecordAdmin role. +//! - **Record admin client**: Adds one follow-up record. All subsequent operations are read-only and can be performed +//! by any address — no capability required. use anyhow::{Result, ensure}; use audit_trail::core::types::{ @@ -24,12 +24,11 @@ use product_common::core_client::CoreClient; async fn main() -> Result<()> { println!("=== Audit Trail: Read-Only Inspection ===\n"); - // `admin` creates the trail and manages roles. - // `record_admin` adds the follow-up record. - let admin = get_funded_audit_trail_client().await?; - let record_admin = get_funded_audit_trail_client().await?; + // Use separate clients to keep write delegation distinct from read-only inspection. + let admin_client = get_funded_audit_trail_client().await?; + let record_admin_client = get_funded_audit_trail_client().await?; - let created = admin + let created_trail = admin_client .create_trail() .with_trail_metadata(ImmutableMetadata::new( "Operations Trail".to_string(), @@ -47,62 +46,63 @@ async fn main() -> Result<()> { None, )) .finish() - .build_and_execute(&admin) + .build_and_execute(&admin_client) .await? .output; - let trail_id = created.trail_id; + let trail_id = created_trail.trail_id; + let record_admin_role = "RecordAdmin"; - admin + admin_client .trail(trail_id) .access() - .for_role("RecordAdmin") + .for_role(record_admin_role) .create(PermissionSet::record_admin_permissions(), None) - .build_and_execute(&admin) + .build_and_execute(&admin_client) .await?; - admin + admin_client .trail(trail_id) .access() - .for_role("RecordAdmin") + .for_role(record_admin_role) .issue_capability(CapabilityIssueOptions { - issued_to: Some(record_admin.sender_address()), + issued_to: Some(record_admin_client.sender_address()), valid_from_ms: None, valid_until_ms: None, }) - .build_and_execute(&admin) + .build_and_execute(&admin_client) .await?; - record_admin + record_admin_client .trail(trail_id) .records() .add(Data::text("Follow-up record"), Some("event:updated".to_string()), None) - .build_and_execute(&record_admin) + .build_and_execute(&record_admin_client) .await?; - let on_chain = admin.trail(trail_id).get().await?; + let on_chain_trail = admin_client.trail(trail_id).get().await?; println!( "Trail summary:\n id = {}\n creator = {}\n created_at = {}\n sequence_number = {}\n immutable_metadata = {:?}\n updatable_metadata = {:?}\n", - on_chain.id.object_id(), - on_chain.creator, - on_chain.created_at, - on_chain.sequence_number, - on_chain.immutable_metadata, - on_chain.updatable_metadata + on_chain_trail.id.object_id(), + on_chain_trail.creator, + on_chain_trail.created_at, + on_chain_trail.sequence_number, + on_chain_trail.immutable_metadata, + on_chain_trail.updatable_metadata ); println!( "Roles: {:?}\nLocking config: {:?}\n", - on_chain.roles.roles.keys().collect::>(), - on_chain.locking_config + on_chain_trail.roles.roles.keys().collect::>(), + on_chain_trail.locking_config ); - let trail = admin.trail(trail_id); - let count = trail.records().record_count().await?; - let initial_record = trail.records().get(0).await?; - let first_page = trail.records().list_page(None, 10).await?; - let record_zero_locked = trail.locking().is_record_locked(0).await?; + let read_only_trail = admin_client.trail(trail_id); + let record_count = read_only_trail.records().record_count().await?; + let initial_record = read_only_trail.records().get(0).await?; + let first_page = read_only_trail.records().list_page(None, 10).await?; + let record_zero_locked = read_only_trail.locking().is_record_locked(0).await?; - println!("Record count: {count}"); + println!("Record count: {record_count}"); println!("Record #0: {:?}", initial_record); println!( "First page size: {} (has_next_page = {})", @@ -111,7 +111,7 @@ async fn main() -> Result<()> { ); println!("Is record #0 locked? {record_zero_locked}"); - ensure!(count == 2); + ensure!(record_count == 2); ensure!(matches!(initial_record.data, Data::Text(ref text) if text == "Initial record")); ensure!(first_page.records.len() == 2); diff --git a/examples/audit-trail/08_delete_audit_trail.rs b/examples/audit-trail/08_delete_audit_trail.rs index 52e55e5a..7b90029a 100644 --- a/examples/audit-trail/08_delete_audit_trail.rs +++ b/examples/audit-trail/08_delete_audit_trail.rs @@ -3,8 +3,8 @@ //! ## Actors //! -//! - **Admin**: Creates the trail and sets up the MaintenanceAdmin role. -//! - **MaintenanceAdmin**: Holds delete permissions. Attempts (and fails) to delete the non-empty trail, then +//! - **Admin client**: Creates the trail and sets up the MaintenanceAdmin role. +//! - **Maintenance admin client**: Holds delete permissions. Attempts (and fails) to delete the non-empty trail, then //! batch-deletes all records before removing the trail itself. use std::collections::HashSet; @@ -22,12 +22,11 @@ use product_common::core_client::CoreClient; async fn main() -> Result<()> { println!("=== Audit Trail: Delete Trail ===\n"); - // `admin` creates the trail and manages roles. - // `maintenance_admin` empties and deletes the trail. - let admin = get_funded_audit_trail_client().await?; - let maintenance_admin = get_funded_audit_trail_client().await?; + // Use a maintenance client to keep deletion permissions separate from trail creation. + let admin_client = get_funded_audit_trail_client().await?; + let maintenance_admin_client = get_funded_audit_trail_client().await?; - let created = admin + let created_trail = admin_client .create_trail() .with_initial_record(InitialRecord::new( Data::text("Initial record"), @@ -35,56 +34,60 @@ async fn main() -> Result<()> { None, )) .finish() - .build_and_execute(&admin) + .build_and_execute(&admin_client) .await? .output; - let trail = admin.trail(created.trail_id); + let trail_id = created_trail.trail_id; + let maintenance_admin_role = "MaintenanceAdmin"; + let admin_trail = admin_client.trail(trail_id); - trail + // The Admin capability authorizes the maintenance role and capability delegation. + admin_trail .access() - .for_role("MaintenanceAdmin") + .for_role(maintenance_admin_role) .create( PermissionSet { permissions: HashSet::from([Permission::DeleteAllRecords, Permission::DeleteAuditTrail]), }, None, ) - .build_and_execute(&admin) + .build_and_execute(&admin_client) .await?; - trail + admin_trail .access() - .for_role("MaintenanceAdmin") + .for_role(maintenance_admin_role) .issue_capability(CapabilityIssueOptions { - issued_to: Some(maintenance_admin.sender_address()), + issued_to: Some(maintenance_admin_client.sender_address()), valid_from_ms: None, valid_until_ms: None, }) - .build_and_execute(&admin) + .build_and_execute(&admin_client) .await?; - let maintenance_trail = maintenance_admin.trail(created.trail_id); + let maintenance_trail = maintenance_admin_client.trail(trail_id); let delete_while_non_empty = maintenance_trail .delete_audit_trail() - .build_and_execute(&maintenance_admin) + .build_and_execute(&maintenance_admin_client) .await; ensure!(delete_while_non_empty.is_err(), "a trail must be empty before deletion"); println!("Deleting the non-empty trail failed as expected.\n"); - let deleted_records = maintenance_trail + // Batch delete returns the exact sequence numbers removed before trail deletion. + let deleted_sequence_numbers = maintenance_trail .records() .delete_records_batch(10) - .build_and_execute(&maintenance_admin) + .build_and_execute(&maintenance_admin_client) .await? .output; - println!("Deleted {deleted_records} record(s) before trail removal.\n"); + println!("Deleted records {deleted_sequence_numbers:?} before trail removal.\n"); ensure!(maintenance_trail.records().record_count().await? == 0); let deleted_trail = maintenance_trail .delete_audit_trail() - .build_and_execute(&maintenance_admin) + .build_and_execute(&maintenance_admin_client) .await? .output; println!( diff --git a/examples/audit-trail/advanced/09_tagged_records.rs b/examples/audit-trail/advanced/09_tagged_records.rs index 4dcf243c..dfb2c75a 100644 --- a/examples/audit-trail/advanced/09_tagged_records.rs +++ b/examples/audit-trail/advanced/09_tagged_records.rs @@ -3,10 +3,10 @@ //! ## Actors //! -//! - **Admin**: Creates the trail, defines the FinanceWriter role restricted to the `finance` tag, and issues a -//! capability bound to `finance_writer`'s address. -//! - **FinanceWriter**: Holds the address-bound capability. Can add `finance`-tagged records but is blocked from -//! writing `legal`-tagged records. +//! - **Admin client**: Creates the trail, defines the FinanceWriter role restricted to the `finance` tag, and issues a +//! capability bound to `finance_writer_client`'s address. +//! - **Finance writer client**: Holds the address-bound capability. Can add `finance`-tagged records but is blocked +//! from writing `legal`-tagged records. use anyhow::{Result, ensure}; use audit_trail::core::types::{CapabilityIssueOptions, Data, InitialRecord, Permission, RoleTags}; @@ -22,10 +22,10 @@ use product_common::core_client::CoreClient; async fn main() -> Result<()> { println!("=== Audit Trail Advanced: Tagged Records ===\n"); - let admin = get_funded_audit_trail_client().await?; - let finance_writer = get_funded_audit_trail_client().await?; + let admin_client = get_funded_audit_trail_client().await?; + let finance_writer_client = get_funded_audit_trail_client().await?; - let created = admin + let created_trail = admin_client .create_trail() .with_record_tags(["finance", "legal"]) .with_initial_record(InitialRecord::new( @@ -34,79 +34,81 @@ async fn main() -> Result<()> { None, )) .finish() - .build_and_execute(&admin) + .build_and_execute(&admin_client) .await? .output; - let trail_id = created.trail_id; + let trail_id = created_trail.trail_id; + let finance_writer_role = "FinanceWriter"; - admin + // The role's tag scope limits writes even when the holder has AddRecord permission. + admin_client .trail(trail_id) .access() - .for_role("FinanceWriter") + .for_role(finance_writer_role) .create( audit_trail::core::types::PermissionSet { permissions: [Permission::AddRecord].into_iter().collect(), }, Some(RoleTags::new(["finance"])), ) - .build_and_execute(&admin) + .build_and_execute(&admin_client) .await?; - let issued = admin + let finance_writer_capability = admin_client .trail(trail_id) .access() - .for_role("FinanceWriter") + .for_role(finance_writer_role) .issue_capability(CapabilityIssueOptions { - issued_to: Some(finance_writer.sender_address()), + issued_to: Some(finance_writer_client.sender_address()), valid_from_ms: None, valid_until_ms: None, }) - .build_and_execute(&admin) + .build_and_execute(&admin_client) .await? .output; println!( "Issued FinanceWriter capability {} to {}\n", - issued.capability_id, - finance_writer.sender_address() + finance_writer_capability.capability_id, + finance_writer_client.sender_address() ); - // The client automatically scans `finance_writer`'s wallet for a capability object that + // The client automatically scans `finance_writer_client`'s wallet for a capability object that // targets this trail and carries the required permission. No explicit capability ID is // needed — the lookup happens in the background on every operation. - let finance_records = finance_writer.trail(trail_id).records(); + let finance_records = finance_writer_client.trail(trail_id).records(); - let added = finance_records + let finance_record_added = finance_records .add( Data::text("Invoice approved"), Some("department:finance".to_string()), Some("finance".to_string()), ) - .build_and_execute(&finance_writer) + .build_and_execute(&finance_writer_client) .await? .output; println!( "Added tagged record at sequence number {} with tag \"finance\".\n", - added.sequence_number + finance_record_added.sequence_number ); - let wrong_tag = finance_records + let wrong_tag_attempt = finance_records .add( Data::text("Legal review completed"), Some("department:legal".to_string()), Some("legal".to_string()), ) - .build_and_execute(&finance_writer) + .build_and_execute(&finance_writer_client) .await; ensure!( - wrong_tag.is_err(), + wrong_tag_attempt.is_err(), "a finance-scoped role must not add a legal-tagged record" ); - let finance_record = finance_records.get(added.sequence_number).await?; + let finance_record = finance_records.get(finance_record_added.sequence_number).await?; println!("Stored tagged record: {:?}", finance_record); ensure!(finance_record.tag.as_deref() == Some("finance")); diff --git a/examples/audit-trail/advanced/10_capability_constraints.rs b/examples/audit-trail/advanced/10_capability_constraints.rs index d56db61f..33390a5f 100644 --- a/examples/audit-trail/advanced/10_capability_constraints.rs +++ b/examples/audit-trail/advanced/10_capability_constraints.rs @@ -3,12 +3,12 @@ //! ## Actors //! -//! - **Admin**: Creates the trail, defines the RecordAdmin role, and issues a capability bound specifically to -//! `intended_writer`'s address. Also performs revocation. -//! - **IntendedWriter**: The authorised holder. Writes a record successfully before revocation, then is blocked after -//! the capability is revoked. -//! - **WrongWriter**: An unauthorised actor who attempts to use the address-bound capability. All write attempts are -//! rejected by the Move contract. +//! - **Admin client**: Creates the trail, defines the RecordAdmin role, and issues a capability bound specifically to +//! `intended_writer_client`'s address. Also performs revocation. +//! - **Intended writer client**: The authorised holder. Writes a record successfully before revocation, then is blocked +//! after the capability is revoked. +//! - **Wrong writer client**: An unauthorised actor who attempts to use the address-bound capability. All write +//! attempts are rejected by the Move contract. use anyhow::{Result, ensure}; use audit_trail::core::types::{CapabilityIssueOptions, Data, InitialRecord, PermissionSet}; @@ -23,91 +23,99 @@ use product_common::core_client::CoreClient; async fn main() -> Result<()> { println!("=== Audit Trail Advanced: Capability Constraints ===\n"); - let admin = get_funded_audit_trail_client().await?; - let intended_writer = get_funded_audit_trail_client().await?; - let wrong_writer = get_funded_audit_trail_client().await?; + let admin_client = get_funded_audit_trail_client().await?; + let intended_writer_client = get_funded_audit_trail_client().await?; + let wrong_writer_client = get_funded_audit_trail_client().await?; - let created = admin + let created_trail = admin_client .create_trail() .with_initial_record(InitialRecord::new(Data::text("Trail created"), None, None)) .finish() - .build_and_execute(&admin) + .build_and_execute(&admin_client) .await? .output; - let trail_id = created.trail_id; + let trail_id = created_trail.trail_id; + let record_admin_role = "RecordAdmin"; - admin + admin_client .trail(trail_id) .access() - .for_role("RecordAdmin") + .for_role(record_admin_role) .create(PermissionSet::record_admin_permissions(), None) - .build_and_execute(&admin) + .build_and_execute(&admin_client) .await?; - let issued = admin + // Address binding means only `intended_writer_client` may use the capability object. + let intended_writer_capability = admin_client .trail(trail_id) .access() - .for_role("RecordAdmin") + .for_role(record_admin_role) .issue_capability(CapabilityIssueOptions { - issued_to: Some(intended_writer.sender_address()), + issued_to: Some(intended_writer_client.sender_address()), valid_from_ms: None, valid_until_ms: None, }) - .build_and_execute(&admin) + .build_and_execute(&admin_client) .await? .output; println!( "Issued capability {} to {}\n", - issued.capability_id, - intended_writer.sender_address() + intended_writer_capability.capability_id, + intended_writer_client.sender_address() ); - let denied = wrong_writer + let wrong_writer_attempt = wrong_writer_client .trail(trail_id) .records() .add(Data::text("Wrong writer"), None, None) - .build_and_execute(&wrong_writer) + .build_and_execute(&wrong_writer_client) .await; ensure!( - denied.is_err(), + wrong_writer_attempt.is_err(), "a capability bound to another address must not be usable" ); - let added = intended_writer + let authorized_record = intended_writer_client .trail(trail_id) .records() .add(Data::text("Authorized writer"), None, None) - .build_and_execute(&intended_writer) + .build_and_execute(&intended_writer_client) .await? .output; - println!("Bound holder added record {} successfully.\n", added.sequence_number); + println!( + "Bound holder added record {} successfully.\n", + authorized_record.sequence_number + ); - admin + admin_client .trail(trail_id) .access() - .revoke_capability(issued.capability_id, issued.valid_until) - .build_and_execute(&admin) + .revoke_capability( + intended_writer_capability.capability_id, + intended_writer_capability.valid_until, + ) + .build_and_execute(&admin_client) .await?; - let revoked_attempt = intended_writer + let revoked_capability_attempt = intended_writer_client .trail(trail_id) .records() .add(Data::text("Should fail after revoke"), None, None) - .build_and_execute(&intended_writer) + .build_and_execute(&intended_writer_client) .await; ensure!( - revoked_attempt.is_err(), + revoked_capability_attempt.is_err(), "revoked capabilities must no longer authorize record writes" ); println!( "Revoked capability {} and verified it can no longer be used.", - issued.capability_id + intended_writer_capability.capability_id ); Ok(()) diff --git a/examples/audit-trail/advanced/11_manage_record_tags.rs b/examples/audit-trail/advanced/11_manage_record_tags.rs index d8c52def..db77465c 100644 --- a/examples/audit-trail/advanced/11_manage_record_tags.rs +++ b/examples/audit-trail/advanced/11_manage_record_tags.rs @@ -3,10 +3,10 @@ //! ## Actors //! -//! - **Admin**: Creates the trail and manages roles. -//! - **TagAdmin**: Holds the TagAdmin capability. Adds and removes entries from the trail's tag registry. -//! - **FinanceWriter**: Holds a `finance`-scoped RecordAdmin capability. Writes a `finance`-tagged record that keeps -//! the `finance` tag in use and therefore unremovable. +//! - **Admin client**: Creates the trail and manages roles. +//! - **Tag admin client**: Holds the TagAdmin capability. Adds and removes entries from the trail's tag registry. +//! - **Finance writer client**: Holds a `finance`-scoped RecordAdmin capability. Writes a `finance`-tagged record that +//! keeps the `finance` tag in use and therefore unremovable. use anyhow::{Result, ensure}; use audit_trail::core::types::{CapabilityIssueOptions, Data, InitialRecord, PermissionSet, RoleTags}; @@ -21,107 +21,114 @@ use product_common::core_client::CoreClient; async fn main() -> Result<()> { println!("=== Audit Trail Advanced: Manage Record Tags ===\n"); - // `admin` creates the trail and manages roles. - // `tag_admin` adds and removes tags from the registry. - // `finance_writer` holds a tag-scoped capability and writes finance records. - let admin = get_funded_audit_trail_client().await?; - let tag_admin = get_funded_audit_trail_client().await?; - let finance_writer = get_funded_audit_trail_client().await?; + // Use separate clients for registry management and tag-scoped record writing. + let admin_client = get_funded_audit_trail_client().await?; + let tag_admin_client = get_funded_audit_trail_client().await?; + let finance_writer_client = get_funded_audit_trail_client().await?; - let created = admin + let created_trail = admin_client .create_trail() .with_record_tags(["finance"]) .with_initial_record(InitialRecord::new(Data::text("Trail created"), None, None)) .finish() - .build_and_execute(&admin) + .build_and_execute(&admin_client) .await? .output; - let trail_id = created.trail_id; + let trail_id = created_trail.trail_id; + let tag_admin_role = "TagAdmin"; + let finance_writer_role = "FinanceWriter"; - admin + admin_client .trail(trail_id) .access() - .for_role("TagAdmin") + .for_role(tag_admin_role) .create(PermissionSet::tag_admin_permissions(), None) - .build_and_execute(&admin) + .build_and_execute(&admin_client) .await?; - admin + admin_client .trail(trail_id) .access() - .for_role("TagAdmin") + .for_role(tag_admin_role) .issue_capability(CapabilityIssueOptions { - issued_to: Some(tag_admin.sender_address()), + issued_to: Some(tag_admin_client.sender_address()), valid_from_ms: None, valid_until_ms: None, }) - .build_and_execute(&admin) + .build_and_execute(&admin_client) .await?; - tag_admin + tag_admin_client .trail(trail_id) .tags() .add("legal") - .build_and_execute(&tag_admin) + .build_and_execute(&tag_admin_client) .await?; - let after_add = admin.trail(trail_id).get().await?; - println!("Registry after adding \"legal\": {:?}\n", after_add.tags.tag_map); - ensure!(after_add.tags.contains_key("finance")); - ensure!(after_add.tags.contains_key("legal")); + let trail_after_tag_add = admin_client.trail(trail_id).get().await?; + println!( + "Registry after adding \"legal\": {:?}\n", + trail_after_tag_add.tags.tag_map + ); + ensure!(trail_after_tag_add.tags.contains_key("finance")); + ensure!(trail_after_tag_add.tags.contains_key("legal")); - admin + // FinanceWriter is scoped to the `finance` tag, which keeps that tag in use. + admin_client .trail(trail_id) .access() - .for_role("FinanceWriter") + .for_role(finance_writer_role) .create( PermissionSet::record_admin_permissions(), Some(RoleTags::new(["finance"])), ) - .build_and_execute(&admin) + .build_and_execute(&admin_client) .await?; - admin + admin_client .trail(trail_id) .access() - .for_role("FinanceWriter") + .for_role(finance_writer_role) .issue_capability(CapabilityIssueOptions { - issued_to: Some(finance_writer.sender_address()), + issued_to: Some(finance_writer_client.sender_address()), valid_from_ms: None, valid_until_ms: None, }) - .build_and_execute(&admin) + .build_and_execute(&admin_client) .await?; - finance_writer + finance_writer_client .trail(trail_id) .records() .add(Data::text("Tagged finance entry"), None, Some("finance".to_string())) - .build_and_execute(&finance_writer) + .build_and_execute(&finance_writer_client) .await?; - let remove_finance = tag_admin + let remove_finance_attempt = tag_admin_client .trail(trail_id) .tags() .remove("finance") - .build_and_execute(&tag_admin) + .build_and_execute(&tag_admin_client) .await; ensure!( - remove_finance.is_err(), + remove_finance_attempt.is_err(), "a tag referenced by a role or record must not be removable" ); - tag_admin + tag_admin_client .trail(trail_id) .tags() .remove("legal") - .build_and_execute(&tag_admin) + .build_and_execute(&tag_admin_client) .await?; - let after_remove = admin.trail(trail_id).get().await?; - println!("Registry after removing \"legal\": {:?}\n", after_remove.tags.tag_map); + let trail_after_tag_remove = admin_client.trail(trail_id).get().await?; + println!( + "Registry after removing \"legal\": {:?}\n", + trail_after_tag_remove.tags.tag_map + ); - ensure!(after_remove.tags.contains_key("finance")); - ensure!(!after_remove.tags.contains_key("legal")); + ensure!(trail_after_tag_remove.tags.contains_key("finance")); + ensure!(!trail_after_tag_remove.tags.contains_key("legal")); Ok(()) } diff --git a/examples/audit-trail/real-world/01_customs_clearance.rs b/examples/audit-trail/real-world/01_customs_clearance.rs index 4e720a50..59415850 100644 --- a/examples/audit-trail/real-world/01_customs_clearance.rs +++ b/examples/audit-trail/real-world/01_customs_clearance.rs @@ -7,16 +7,17 @@ //! //! ## Actors //! -//! - **Admin**: Creates the trail and sets up all roles and capabilities. -//! - **DocsOperator**: Handles document submission (invoices, packing lists). Writes only `documents`-tagged records. -//! - **ExportBroker**: Files export declarations and records clearance decisions at the origin. Writes only -//! `export`-tagged records. -//! - **ImportBroker**: Handles duty assessment and import clearance at the destination. Writes only `import`-tagged +//! - **Admin client**: Creates the trail and sets up all roles and capabilities. +//! - **Docs operator client**: Handles document submission (invoices, packing lists). Writes only `documents`-tagged //! records. -//! - **Inspector**: Records the outcome of a customs physical inspection. Writes only `inspection`-tagged records; the -//! role is created mid-process when an inspection is triggered. -//! - **Supervisor**: Updates the mutable trail metadata (processing status). No record-write permissions. -//! - **LockingAdmin**: Freezes the trail once the shipment is fully cleared. +//! - **Export broker client**: Files export declarations and records clearance decisions at the origin. Writes only +//! `export`-tagged records. +//! - **Import broker client**: Handles duty assessment and import clearance at the destination. Writes only +//! `import`-tagged records. +//! - **Inspector client**: Records the outcome of a customs physical inspection. Writes only `inspection`-tagged +//! records; the role is created mid-process when an inspection is triggered. +//! - **Supervisor client**: Updates the mutable trail metadata (processing status). No record-write permissions. +//! - **Locking admin client**: Freezes the trail once the shipment is fully cleared. //! //! ## How the trail is used //! @@ -39,19 +40,19 @@ use sha2::{Digest, Sha256}; async fn main() -> Result<()> { println!("=== Customs Clearance ===\n"); - let admin = get_funded_audit_trail_client().await?; - let docs_operator = get_funded_audit_trail_client().await?; - let export_broker = get_funded_audit_trail_client().await?; - let import_broker = get_funded_audit_trail_client().await?; - let supervisor = get_funded_audit_trail_client().await?; - let locking_admin = get_funded_audit_trail_client().await?; - let inspector = get_funded_audit_trail_client().await?; + let admin_client = get_funded_audit_trail_client().await?; + let docs_operator_client = get_funded_audit_trail_client().await?; + let export_broker_client = get_funded_audit_trail_client().await?; + let import_broker_client = get_funded_audit_trail_client().await?; + let supervisor_client = get_funded_audit_trail_client().await?; + let locking_admin_client = get_funded_audit_trail_client().await?; + let inspector_client = get_funded_audit_trail_client().await?; // === Create the customs-clearance trail === println!("Creating a customs-clearance trail..."); - let created = admin + let created_trail = admin_client .create_trail() .with_record_tags(["documents", "export", "import", "inspection"]) .with_trail_metadata(ImmutableMetadata::new( @@ -70,75 +71,78 @@ async fn main() -> Result<()> { Some("documents".to_string()), )) .finish() - .build_and_execute(&admin) + .build_and_execute(&admin_client) .await? .output; - let trail_id = created.trail_id; + let trail_id = created_trail.trail_id; // === Set up roles and capabilities for each actor === + // The Admin capability delegates one tag-scoped writer role per operational actor. issue_tagged_record_role( - &admin, + &admin_client, trail_id, "DocsOperator", "documents", - docs_operator.sender_address(), + docs_operator_client.sender_address(), ) .await?; issue_tagged_record_role( - &admin, + &admin_client, trail_id, "ExportBroker", "export", - export_broker.sender_address(), + export_broker_client.sender_address(), ) .await?; issue_tagged_record_role( - &admin, + &admin_client, trail_id, "ImportBroker", "import", - import_broker.sender_address(), + import_broker_client.sender_address(), ) .await?; - admin + let supervisor_role = "Supervisor"; + admin_client .trail(trail_id) .access() - .for_role("Supervisor") + .for_role(supervisor_role) .create(PermissionSet::metadata_admin_permissions(), None) - .build_and_execute(&admin) + .build_and_execute(&admin_client) .await?; - admin + admin_client .trail(trail_id) .access() - .for_role("Supervisor") + .for_role(supervisor_role) .issue_capability(CapabilityIssueOptions { - issued_to: Some(supervisor.sender_address()), + issued_to: Some(supervisor_client.sender_address()), valid_from_ms: None, valid_until_ms: None, }) - .build_and_execute(&admin) + .build_and_execute(&admin_client) .await?; - admin + let locking_admin_role = "LockingAdmin"; + admin_client .trail(trail_id) .access() - .for_role("LockingAdmin") + .for_role(locking_admin_role) .create(PermissionSet::locking_admin_permissions(), None) - .build_and_execute(&admin) + .build_and_execute(&admin_client) .await?; - admin + admin_client .trail(trail_id) .access() - .for_role("LockingAdmin") + .for_role(locking_admin_role) .issue_capability(CapabilityIssueOptions { - issued_to: Some(locking_admin.sender_address()), + issued_to: Some(locking_admin_client.sender_address()), valid_from_ms: None, valid_until_ms: None, }) - .build_and_execute(&admin) + .build_and_execute(&admin_client) .await?; // === Document submission === @@ -146,7 +150,7 @@ async fn main() -> Result<()> { // Documents are stored off-chain in an access-controlled environment (e.g. a TWIN node). // Only the SHA-256 fingerprint is committed on-chain for tamper-evidence. let invoice_hash = Sha256::digest(b"invoice-SHP-2026-CLEAR-001-v1.pdf"); - let docs_uploaded = docs_operator + let docs_uploaded = docs_operator_client .trail(trail_id) .records() .add( @@ -154,21 +158,21 @@ async fn main() -> Result<()> { Some("event:documents_uploaded".to_string()), Some("documents".to_string()), ) - .build_and_execute(&docs_operator) + .build_and_execute(&docs_operator_client) .await? .output; println!("Docs operator added record #{}.\n", docs_uploaded.sequence_number); - supervisor + supervisor_client .trail(trail_id) .update_metadata(Some("Status: Awaiting Export Clearance".to_string())) - .build_and_execute(&supervisor) + .build_and_execute(&supervisor_client) .await?; // === Export clearance === - let export_filed = export_broker + let export_filed = export_broker_client .trail(trail_id) .records() .add( @@ -176,11 +180,11 @@ async fn main() -> Result<()> { Some("event:export_declaration_filed".to_string()), Some("export".to_string()), ) - .build_and_execute(&export_broker) + .build_and_execute(&export_broker_client) .await? .output; - let export_cleared = export_broker + let export_cleared = export_broker_client .trail(trail_id) .records() .add( @@ -188,7 +192,7 @@ async fn main() -> Result<()> { Some("event:export_cleared".to_string()), Some("export".to_string()), ) - .build_and_execute(&export_broker) + .build_and_execute(&export_broker_client) .await? .output; @@ -197,17 +201,17 @@ async fn main() -> Result<()> { export_filed.sequence_number, export_cleared.sequence_number ); - supervisor + supervisor_client .trail(trail_id) .update_metadata(Some("Status: Awaiting Import Clearance".to_string())) - .build_and_execute(&supervisor) + .build_and_execute(&supervisor_client) .await?; // === Inspection gate === // The import broker does not hold an inspection-scoped capability at this point. // The write attempt must fail to prove that tag-based access control is enforced. - let denied_inspection = import_broker + let denied_inspection_attempt = import_broker_client .trail(trail_id) .records() .add( @@ -215,19 +219,26 @@ async fn main() -> Result<()> { Some("event:invalid_inspection_write".to_string()), Some("inspection".to_string()), ) - .build_and_execute(&import_broker) + .build_and_execute(&import_broker_client) .await; ensure!( - denied_inspection.is_err(), + denied_inspection_attempt.is_err(), "inspection-tagged writes should fail before an inspection-scoped capability exists" ); println!("Inspection write was correctly denied before the inspector role existed.\n"); // A customs inspection is triggered; the inspector role is created and issued mid-process. - issue_tagged_record_role(&admin, trail_id, "Inspector", "inspection", inspector.sender_address()).await?; + issue_tagged_record_role( + &admin_client, + trail_id, + "Inspector", + "inspection", + inspector_client.sender_address(), + ) + .await?; - let inspection_done = inspector + let inspection_done = inspector_client .trail(trail_id) .records() .add( @@ -235,7 +246,7 @@ async fn main() -> Result<()> { Some("event:inspection_completed".to_string()), Some("inspection".to_string()), ) - .build_and_execute(&inspector) + .build_and_execute(&inspector_client) .await? .output; @@ -243,7 +254,7 @@ async fn main() -> Result<()> { // === Import clearance === - let duty_assessed = import_broker + let duty_assessed = import_broker_client .trail(trail_id) .records() .add( @@ -251,11 +262,11 @@ async fn main() -> Result<()> { Some("event:duty_assessed".to_string()), Some("import".to_string()), ) - .build_and_execute(&import_broker) + .build_and_execute(&import_broker_client) .await? .output; - let import_cleared = import_broker + let import_cleared = import_broker_client .trail(trail_id) .records() .add( @@ -263,7 +274,7 @@ async fn main() -> Result<()> { Some("event:import_cleared".to_string()), Some("import".to_string()), ) - .build_and_execute(&import_broker) + .build_and_execute(&import_broker_client) .await? .output; @@ -272,28 +283,28 @@ async fn main() -> Result<()> { duty_assessed.sequence_number, import_cleared.sequence_number ); - supervisor + supervisor_client .trail(trail_id) .update_metadata(Some("Status: Cleared".to_string())) - .build_and_execute(&supervisor) + .build_and_execute(&supervisor_client) .await?; // === Final lock and verification === - locking_admin + locking_admin_client .trail(trail_id) .locking() .update_write_lock(TimeLock::Infinite) - .build_and_execute(&locking_admin) + .build_and_execute(&locking_admin_client) .await?; - let after_lock = admin.trail(trail_id).get().await?; + let trail_after_lock = admin_client.trail(trail_id).get().await?; println!( "Write lock after clearance: {:?}\n", - after_lock.locking_config.write_lock + trail_after_lock.locking_config.write_lock ); - let late_note = docs_operator + let late_note_attempt = docs_operator_client .trail(trail_id) .records() .add( @@ -301,19 +312,19 @@ async fn main() -> Result<()> { Some("event:late_note".to_string()), Some("documents".to_string()), ) - .build_and_execute(&docs_operator) + .build_and_execute(&docs_operator_client) .await; ensure!( - late_note.is_err(), + late_note_attempt.is_err(), "cleared customs trail should reject late writes after the final lock" ); - let trail = admin.trail(trail_id); - let first_page = trail.records().list_page(None, 20).await?; + let admin_trail = admin_client.trail(trail_id); + let customs_records_page = admin_trail.records().list_page(None, 20).await?; println!("Recorded customs events:"); - for (sequence_number, record) in &first_page.records { + for (sequence_number, record) in &customs_records_page.records { println!( " #{} | {:?} | tag={:?} | {:?}", sequence_number, record.data, record.tag, record.metadata @@ -321,11 +332,11 @@ async fn main() -> Result<()> { } ensure!( - first_page.records.len() == 7, + customs_records_page.records.len() == 7, "expected 7 customs records including the initial case-opened record" ); ensure!( - trail.get().await?.updatable_metadata.as_deref() == Some("Status: Cleared"), + admin_trail.get().await?.updatable_metadata.as_deref() == Some("Status: Cleared"), "customs case should finish in cleared state" ); diff --git a/examples/audit-trail/real-world/02_clinical_trial.rs b/examples/audit-trail/real-world/02_clinical_trial.rs index 0a427f5f..b67c2686 100644 --- a/examples/audit-trail/real-world/02_clinical_trial.rs +++ b/examples/audit-trail/real-world/02_clinical_trial.rs @@ -8,17 +8,17 @@ //! //! ## Actors //! -//! - **Admin**: Creates the trail and sets up all roles and capabilities. -//! - **Enroller**: Writes enrollment events. Restricted to the `enrollment` tag. -//! - **SafetyOfficer**: Records adverse events and safety observations. Restricted to `safety`. -//! - **EfficacyReviewer**: Records treatment outcomes. Restricted to `efficacy`. -//! - **PkAnalyst**: Records pharmacokinetic results. Restricted to the `pk` tag that is added mid-study when a PK -//! sub-study is initiated. -//! - **Monitor**: Updates the mutable study-phase metadata. Access is time-windowed to the active study period (90 days -//! from now). -//! - **DataSafetyBoard**: Controls write and delete locks. Freezes the dataset after review. -//! - **Regulator**: Read-only verifier. In production this would use `AuditTrailClientReadOnly` (no signing key); here -//! a funded client is used to keep the example self-contained. +//! - **Admin client**: Creates the trail and sets up all roles and capabilities. +//! - **Enroller client**: Writes enrollment events. Restricted to the `enrollment` tag. +//! - **Safety officer client**: Records adverse events and safety observations. Restricted to `safety`. +//! - **Efficacy reviewer client**: Records treatment outcomes. Restricted to `efficacy`. +//! - **PK analyst client**: Records pharmacokinetic results. Restricted to the `pk` tag that is added mid-study when a +//! PK sub-study is initiated. +//! - **Monitor client**: Updates the mutable study-phase metadata. Access is time-windowed to the active study period +//! (90 days from now). +//! - **Data safety board client**: Controls write and delete locks. Freezes the dataset after review. +//! - **Regulator client**: Read-only verifier. In production this would use `AuditTrailClientReadOnly` (no signing +//! key); here a funded client is used to keep the example self-contained. //! //! ## How the trail is used //! @@ -43,21 +43,21 @@ use product_common::core_client::CoreClient; async fn main() -> Result<()> { println!("=== Clinical Trial Data Integrity ===\n"); - let admin = get_funded_audit_trail_client().await?; - let enroller = get_funded_audit_trail_client().await?; - let safety_officer = get_funded_audit_trail_client().await?; - let efficacy_reviewer = get_funded_audit_trail_client().await?; - let pk_analyst = get_funded_audit_trail_client().await?; - let monitor = get_funded_audit_trail_client().await?; - let data_safety_board = get_funded_audit_trail_client().await?; - let regulator = get_funded_audit_trail_client().await?; + let admin_client = get_funded_audit_trail_client().await?; + let enroller_client = get_funded_audit_trail_client().await?; + let safety_officer_client = get_funded_audit_trail_client().await?; + let efficacy_reviewer_client = get_funded_audit_trail_client().await?; + let pk_analyst_client = get_funded_audit_trail_client().await?; + let monitor_client = get_funded_audit_trail_client().await?; + let data_safety_board_client = get_funded_audit_trail_client().await?; + let regulator_client = get_funded_audit_trail_client().await?; // ----------------------------------------------------------------------- // 1. Create the trial trail // ----------------------------------------------------------------------- println!("Creating the clinical-trial audit trail..."); - let created = admin + let created_trail = admin_client .create_trail() .with_record_tags(["enrollment", "safety", "efficacy"]) .with_trail_metadata(ImmutableMetadata::new( @@ -76,11 +76,11 @@ async fn main() -> Result<()> { Some("enrollment".to_string()), )) .finish() - .build_and_execute(&admin) + .build_and_execute(&admin_client) .await? .output; - let trail_id = created.trail_id; + let trail_id = created_trail.trail_id; println!("Trail created with ID {trail_id}\n"); // ----------------------------------------------------------------------- @@ -88,31 +88,40 @@ async fn main() -> Result<()> { // ----------------------------------------------------------------------- println!("Defining study roles..."); - issue_tagged_record_role(&admin, trail_id, "Enroller", "enrollment", enroller.sender_address()).await?; + // The Admin capability delegates one tag-scoped writer role per study function. issue_tagged_record_role( - &admin, + &admin_client, + trail_id, + "Enroller", + "enrollment", + enroller_client.sender_address(), + ) + .await?; + issue_tagged_record_role( + &admin_client, trail_id, "SafetyOfficer", "safety", - safety_officer.sender_address(), + safety_officer_client.sender_address(), ) .await?; issue_tagged_record_role( - &admin, + &admin_client, trail_id, "EfficacyReviewer", "efficacy", - efficacy_reviewer.sender_address(), + efficacy_reviewer_client.sender_address(), ) .await?; // Monitor can update metadata (study phase) but only during the study window. - admin + let monitor_role = "Monitor"; + admin_client .trail(trail_id) .access() - .for_role("Monitor") + .for_role(monitor_role) .create(PermissionSet::metadata_admin_permissions(), None) - .build_and_execute(&admin) + .build_and_execute(&admin_client) .await?; let now_ms = std::time::SystemTime::now() @@ -121,38 +130,39 @@ async fn main() -> Result<()> { // Monitor access is valid for 90 days from now. let study_end_ms = now_ms + 90 * 24 * 60 * 60 * 1000; - admin + admin_client .trail(trail_id) .access() - .for_role("Monitor") + .for_role(monitor_role) .issue_capability(CapabilityIssueOptions { - issued_to: Some(monitor.sender_address()), + issued_to: Some(monitor_client.sender_address()), valid_from_ms: Some(now_ms), valid_until_ms: Some(study_end_ms), }) - .build_and_execute(&admin) + .build_and_execute(&admin_client) .await?; println!("Monitor capability issued (valid for 90 days from now, ends at timestamp {study_end_ms})\n"); // Data Safety Board can manage locking. - admin + let data_safety_board_role = "DataSafetyBoard"; + admin_client .trail(trail_id) .access() - .for_role("DataSafetyBoard") + .for_role(data_safety_board_role) .create(PermissionSet::locking_admin_permissions(), None) - .build_and_execute(&admin) + .build_and_execute(&admin_client) .await?; - admin + admin_client .trail(trail_id) .access() - .for_role("DataSafetyBoard") + .for_role(data_safety_board_role) .issue_capability(CapabilityIssueOptions { - issued_to: Some(data_safety_board.sender_address()), + issued_to: Some(data_safety_board_client.sender_address()), valid_from_ms: None, valid_until_ms: None, }) - .build_and_execute(&admin) + .build_and_execute(&admin_client) .await?; // ----------------------------------------------------------------------- @@ -160,7 +170,7 @@ async fn main() -> Result<()> { // ----------------------------------------------------------------------- println!("--- Enrollment Phase ---"); - let enrolled = enroller + let enrolled_record = enroller_client .trail(trail_id) .records() .add( @@ -168,17 +178,17 @@ async fn main() -> Result<()> { Some("event:patient_enrolled".to_string()), Some("enrollment".to_string()), ) - .build_and_execute(&enroller) + .build_and_execute(&enroller_client) .await? .output; - println!("Enroller added record #{}.\n", enrolled.sequence_number); + println!("Enroller added record #{}.\n", enrolled_record.sequence_number); // ----------------------------------------------------------------------- // 4. Add safety and efficacy records // ----------------------------------------------------------------------- println!("--- Study Data Collection ---"); - let safety_event = safety_officer + let safety_event_record = safety_officer_client .trail(trail_id) .records() .add( @@ -186,11 +196,11 @@ async fn main() -> Result<()> { Some("event:adverse_event".to_string()), Some("safety".to_string()), ) - .build_and_execute(&safety_officer) + .build_and_execute(&safety_officer_client) .await? .output; - let efficacy_record = efficacy_reviewer + let efficacy_outcome_record = efficacy_reviewer_client .trail(trail_id) .records() .add( @@ -198,13 +208,13 @@ async fn main() -> Result<()> { Some("event:efficacy_observed".to_string()), Some("efficacy".to_string()), ) - .build_and_execute(&efficacy_reviewer) + .build_and_execute(&efficacy_reviewer_client) .await? .output; println!( "SafetyOfficer added record #{}, EfficacyReviewer added record #{}.\n", - safety_event.sequence_number, efficacy_record.sequence_number + safety_event_record.sequence_number, efficacy_outcome_record.sequence_number ); // ----------------------------------------------------------------------- @@ -213,12 +223,24 @@ async fn main() -> Result<()> { println!("--- Mid-Study Amendment ---"); // Admin adds the new tag and creates a role for the PK analyst. - admin.trail(trail_id).tags().add("pk").build_and_execute(&admin).await?; + admin_client + .trail(trail_id) + .tags() + .add("pk") + .build_and_execute(&admin_client) + .await?; println!("Added tag 'pk' (pharmacokinetics) to the trail."); - issue_tagged_record_role(&admin, trail_id, "PkAnalyst", "pk", pk_analyst.sender_address()).await?; + issue_tagged_record_role( + &admin_client, + trail_id, + "PkAnalyst", + "pk", + pk_analyst_client.sender_address(), + ) + .await?; - let pk_record = pk_analyst + let pk_result_record = pk_analyst_client .trail(trail_id) .records() .add( @@ -226,30 +248,30 @@ async fn main() -> Result<()> { Some("event:pk_result".to_string()), Some("pk".to_string()), ) - .build_and_execute(&pk_analyst) + .build_and_execute(&pk_analyst_client) .await? .output; - println!("PkAnalyst added record #{}.\n", pk_record.sequence_number); + println!("PkAnalyst added record #{}.\n", pk_result_record.sequence_number); // ----------------------------------------------------------------------- // 6. Deletion window protects recent records // ----------------------------------------------------------------------- println!("--- Deletion Window Enforcement ---"); - let delete_attempt = pk_analyst + let protected_delete_attempt = pk_analyst_client .trail(trail_id) .records() - .delete(pk_record.sequence_number) - .build_and_execute(&pk_analyst) + .delete(pk_result_record.sequence_number) + .build_and_execute(&pk_analyst_client) .await; ensure!( - delete_attempt.is_err(), + protected_delete_attempt.is_err(), "recent records must be protected by the count-based deletion window" ); println!( "Record #{} is within the deletion window (newest 3) and cannot be deleted.\n", - pk_record.sequence_number + pk_result_record.sequence_number ); // ----------------------------------------------------------------------- @@ -257,14 +279,17 @@ async fn main() -> Result<()> { // ----------------------------------------------------------------------- println!("--- Metadata Update ---"); - monitor + monitor_client .trail(trail_id) .update_metadata(Some("Phase: Data Review".to_string())) - .build_and_execute(&monitor) + .build_and_execute(&monitor_client) .await?; - let current_state = admin.trail(trail_id).get().await?; - println!("Study phase updated to: {:?}\n", current_state.updatable_metadata); + let trail_after_phase_update = admin_client.trail(trail_id).get().await?; + println!( + "Study phase updated to: {:?}\n", + trail_after_phase_update.updatable_metadata + ); // ----------------------------------------------------------------------- // 8. Data Safety Board locks the study dataset @@ -275,14 +300,14 @@ async fn main() -> Result<()> { // after which the dataset becomes permanently locked. let lock_until_ms = now_ms + 365 * 24 * 60 * 60 * 1000; // 1 year from now - data_safety_board + data_safety_board_client .trail(trail_id) .locking() .update_write_lock(TimeLock::UnlockAtMs(lock_until_ms)) - .build_and_execute(&data_safety_board) + .build_and_execute(&data_safety_board_client) .await?; - let locked_trail = admin.trail(trail_id).get().await?; + let locked_trail = admin_client.trail(trail_id).get().await?; println!( "Write lock set to UnlockAtMs({}) — writes blocked until that timestamp.\n", lock_until_ms @@ -290,17 +315,17 @@ async fn main() -> Result<()> { println!("Current locking config: {:?}\n", locked_trail.locking_config); // Also lock the trail from deletion permanently. - data_safety_board + data_safety_board_client .trail(trail_id) .locking() .update_delete_trail_lock(TimeLock::Infinite) - .build_and_execute(&data_safety_board) + .build_and_execute(&data_safety_board_client) .await?; - let final_locking = admin.trail(trail_id).get().await?; + let final_locking_trail = admin_client.trail(trail_id).get().await?; println!( "Delete-trail lock set to {:?} — trail cannot be deleted.\n", - final_locking.locking_config.delete_trail_lock + final_locking_trail.locking_config.delete_trail_lock ); // ----------------------------------------------------------------------- @@ -309,17 +334,20 @@ async fn main() -> Result<()> { println!("--- Regulator Verification ---"); // In production the regulator would use AuditTrailClientReadOnly (no signer). - let regulator_handle = regulator.trail(trail_id); + let regulator_trail = regulator_client.trail(trail_id); - let on_chain = regulator_handle.get().await?; - println!("Protocol: {:?}", on_chain.immutable_metadata); - println!("Phase: {:?}", on_chain.updatable_metadata); - println!("Roles: {:?}", on_chain.roles.roles.keys().collect::>()); - println!("Tags: {:?}", on_chain.tags.tag_map.keys().collect::>()); + let on_chain_trail = regulator_trail.get().await?; + println!("Protocol: {:?}", on_chain_trail.immutable_metadata); + println!("Phase: {:?}", on_chain_trail.updatable_metadata); + println!("Roles: {:?}", on_chain_trail.roles.roles.keys().collect::>()); + println!( + "Tags: {:?}", + on_chain_trail.tags.tag_map.keys().collect::>() + ); - let first_page = regulator_handle.records().list_page(None, 20).await?; - println!("\nVerified records ({} total):", first_page.records.len()); - for (seq, record) in &first_page.records { + let verified_records_page = regulator_trail.records().list_page(None, 20).await?; + println!("\nVerified records ({} total):", verified_records_page.records.len()); + for (seq, record) in &verified_records_page.records { println!(" #{} | tag={:?} | {:?}", seq, record.tag, record.metadata); } @@ -327,27 +355,27 @@ async fn main() -> Result<()> { // 10. Assertions // ----------------------------------------------------------------------- ensure!( - first_page.records.len() == 5, + verified_records_page.records.len() == 5, "expected 5 records (initial + enrolled + safety + efficacy + pk)" ); ensure!( - on_chain.tags.tag_map.contains_key("pk"), + on_chain_trail.tags.tag_map.contains_key("pk"), "the 'pk' tag must exist after mid-study amendment" ); ensure!( - on_chain.locking_config.delete_record_window == LockingWindow::CountBased { count: 3 }, + on_chain_trail.locking_config.delete_record_window == LockingWindow::CountBased { count: 3 }, "deletion window must remain count-based with count 3" ); ensure!( - on_chain.locking_config.delete_trail_lock == TimeLock::Infinite, + on_chain_trail.locking_config.delete_trail_lock == TimeLock::Infinite, "delete-trail lock must be Infinite" ); ensure!( - matches!(on_chain.locking_config.write_lock, TimeLock::UnlockAtMs(_)), + matches!(on_chain_trail.locking_config.write_lock, TimeLock::UnlockAtMs(_)), "write lock must be UnlockAtMs" ); ensure!( - on_chain.updatable_metadata.as_deref() == Some("Phase: Data Review"), + on_chain_trail.updatable_metadata.as_deref() == Some("Phase: Data Review"), "study phase must be 'Data Review'" ); diff --git a/examples/audit-trail/real-world/03_digital_product_passport.rs b/examples/audit-trail/real-world/03_digital_product_passport.rs index dadef6da..f20b6dca 100644 --- a/examples/audit-trail/real-world/03_digital_product_passport.rs +++ b/examples/audit-trail/real-world/03_digital_product_passport.rs @@ -16,14 +16,14 @@ //! //! ## 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 +//! - **Manufacturer client**: Creates the DPP, publishes manufacturing data, and administers roles and capabilities. +//! - **Lifecycle manager client**: Updates the mutable lifecycle-stage metadata. +//! - **Distributor client**: Writes logistics and handover records. +//! - **Consumer client**: Writes the commissioning / in-use activation record. +//! - **Service technician client**: 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. +//! - **Recycler client**: Prepared for future end-of-life events through a recycling-scoped capability. +//! - **EPRO client**: Records reward policy and the reward-payout evidence for verified maintenance. //! //! ## How the trail is used as a DPP //! @@ -50,28 +50,34 @@ use product_common::test_utils::InMemSigner; 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()); + let manufacturer_client = get_funded_audit_trail_client().await?; + let lifecycle_manager_client = get_funded_audit_trail_client().await?; + let distributor_client = get_funded_audit_trail_client().await?; + let consumer_client = get_funded_audit_trail_client().await?; + let service_technician_client = get_funded_audit_trail_client().await?; + let recycler_client = get_funded_audit_trail_client().await?; + let epro_client = get_funded_audit_trail_client().await?; + + println!("Manufacturer wallet: {}", manufacturer_client.sender_address()); + println!( + "Lifecycle manager wallet: {}", + lifecycle_manager_client.sender_address() + ); + println!("Distributor wallet: {}", distributor_client.sender_address()); + println!("Consumer wallet: {}", consumer_client.sender_address()); + println!( + "Service technician wallet: {}", + service_technician_client.sender_address() + ); + println!("Recycler wallet: {}", recycler_client.sender_address()); + println!("EPRO wallet: {}\n", epro_client.sender_address()); // --------------------------------------------------------------------- // 1. Create the DPP audit trail // --------------------------------------------------------------------- println!("Creating the DPP trail for EcoBike's battery..."); - let created = manufacturer + let created_trail = manufacturer_client .create_trail() .with_record_tags([ "manufacturing", @@ -94,11 +100,11 @@ async fn main() -> Result<()> { Some("manufacturing".to_string()), )) .finish() - .build_and_execute(&manufacturer) + .build_and_execute(&manufacturer_client) .await? .output; - let trail_id = created.trail_id; + let trail_id = created_trail.trail_id; println!("Trail created with ID {trail_id}\n"); // --------------------------------------------------------------------- @@ -106,56 +112,65 @@ async fn main() -> Result<()> { // --------------------------------------------------------------------- println!("Configuring DPP actor roles..."); + // The manufacturer keeps the built-in Admin capability and delegates scoped lifecycle roles. issue_tagged_record_role( - &manufacturer, + &manufacturer_client, trail_id, "Manufacturer", "manufacturing", - manufacturer.sender_address(), + manufacturer_client.sender_address(), ) .await?; issue_tagged_record_role( - &manufacturer, + &manufacturer_client, trail_id, "Distributor", "logistics", - distributor.sender_address(), + distributor_client.sender_address(), ) .await?; issue_tagged_record_role( - &manufacturer, + &manufacturer_client, trail_id, "Consumer", "ownership", - consumer.sender_address(), + consumer_client.sender_address(), ) .await?; issue_tagged_record_role( - &manufacturer, + &manufacturer_client, trail_id, "Recycler", "recycling", - recycler.sender_address(), + recycler_client.sender_address(), + ) + .await?; + issue_tagged_record_role( + &manufacturer_client, + trail_id, + "EPRO", + "rewards", + epro_client.sender_address(), ) .await?; - issue_tagged_record_role(&manufacturer, trail_id, "EPRO", "rewards", epro.sender_address()).await?; - manufacturer + let service_technician_role = "ServiceTechnician"; + manufacturer_client .trail(trail_id) .access() - .for_role("ServiceTechnician") + .for_role(service_technician_role) .create( PermissionSet::record_admin_permissions(), Some(RoleTags::new(["maintenance"])), ) - .build_and_execute(&manufacturer) + .build_and_execute(&manufacturer_client) .await?; issue_metadata_role( - &manufacturer, + &manufacturer_client, trail_id, "LifecycleManager", - lifecycle_manager.sender_address(), + lifecycle_manager_client.sender_address(), ) .await?; @@ -164,7 +179,7 @@ async fn main() -> Result<()> { // --------------------------------------------------------------------- println!("Publishing product details, service-network context, and reward policy..."); - manufacturer + manufacturer_client .trail(trail_id) .records() .add( @@ -174,10 +189,11 @@ async fn main() -> Result<()> { Some("event:product_details_published".to_string()), Some("manufacturing".to_string()), ) - .build_and_execute(&manufacturer) + .build_and_execute(&manufacturer_client) .await?; - epro.trail(trail_id) + epro_client + .trail(trail_id) .records() .add( Data::text( @@ -186,16 +202,16 @@ async fn main() -> Result<()> { Some("event:reward_policy_published".to_string()), Some("rewards".to_string()), ) - .build_and_execute(&epro) + .build_and_execute(&epro_client) .await?; - lifecycle_manager + lifecycle_manager_client .trail(trail_id) .update_metadata(Some("Lifecycle Stage: In Distribution".to_string())) - .build_and_execute(&lifecycle_manager) + .build_and_execute(&lifecycle_manager_client) .await?; - distributor + distributor_client .trail(trail_id) .records() .add( @@ -205,16 +221,16 @@ async fn main() -> Result<()> { Some("event:distributed".to_string()), Some("logistics".to_string()), ) - .build_and_execute(&distributor) + .build_and_execute(&distributor_client) .await?; - lifecycle_manager + lifecycle_manager_client .trail(trail_id) .update_metadata(Some("Lifecycle Stage: In Use".to_string())) - .build_and_execute(&lifecycle_manager) + .build_and_execute(&lifecycle_manager_client) .await?; - consumer + consumer_client .trail(trail_id) .records() .add( @@ -224,7 +240,7 @@ async fn main() -> Result<()> { Some("event:commissioned".to_string()), Some("ownership".to_string()), ) - .build_and_execute(&consumer) + .build_and_execute(&consumer_client) .await?; // --------------------------------------------------------------------- @@ -232,13 +248,17 @@ async fn main() -> Result<()> { // --------------------------------------------------------------------- println!("Technician reviews the current DPP history..."); - let history_before_service = service_technician.trail(trail_id).records().list_page(None, 20).await?; + let history_before_service = service_technician_client + .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 + let denied_before_grant = service_technician_client .trail(trail_id) .records() .add( @@ -246,7 +266,7 @@ async fn main() -> Result<()> { Some("event:unauthorized_maintenance_attempt".to_string()), Some("maintenance".to_string()), ) - .build_and_execute(&service_technician) + .build_and_execute(&service_technician_client) .await; ensure!( @@ -260,28 +280,29 @@ async fn main() -> Result<()> { .as_millis() as u64; let technician_valid_until_ms = now_ms + 30 * 24 * 60 * 60 * 1000; - let issued_technician_cap = manufacturer + // The technician can read the passport before authorization, but needs a maintenance capability to write. + let service_technician_capability = manufacturer_client .trail(trail_id) .access() - .for_role("ServiceTechnician") + .for_role(service_technician_role) .issue_capability(CapabilityIssueOptions { - issued_to: Some(service_technician.sender_address()), + issued_to: Some(service_technician_client.sender_address()), valid_from_ms: Some(now_ms), valid_until_ms: Some(technician_valid_until_ms), }) - .build_and_execute(&manufacturer) + .build_and_execute(&manufacturer_client) .await? .output; println!( "Issued ServiceTechnician capability {} (valid until {}).\n", - issued_technician_cap.capability_id, technician_valid_until_ms + service_technician_capability.capability_id, technician_valid_until_ms ); - lifecycle_manager + lifecycle_manager_client .trail(trail_id) .update_metadata(Some("Lifecycle Stage: Maintenance In Progress".to_string())) - .build_and_execute(&lifecycle_manager) + .build_and_execute(&lifecycle_manager_client) .await?; // --------------------------------------------------------------------- @@ -289,7 +310,7 @@ async fn main() -> Result<()> { // --------------------------------------------------------------------- println!("Recording the annual maintenance event..."); - let maintenance_event = service_technician + let maintenance_event_record = service_technician_client .trail(trail_id) .records() .add( @@ -299,42 +320,42 @@ async fn main() -> Result<()> { Some("event:annual_maintenance".to_string()), Some("maintenance".to_string()), ) - .build_and_execute(&service_technician) + .build_and_execute(&service_technician_client) .await? .output; println!( "Service technician added maintenance record #{}.\n", - maintenance_event.sequence_number + maintenance_event_record.sequence_number ); - let reward_event = epro + let reward_event_record = epro_client .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() + maintenance_event_record.sequence_number, + service_technician_client.sender_address() )), Some("event:lcc_reward_distributed".to_string()), Some("rewards".to_string()), ) - .build_and_execute(&epro) + .build_and_execute(&epro_client) .await? .output; println!( "EPRO added reward record #{} for the verified maintenance event.\n", - reward_event.sequence_number + reward_event_record.sequence_number ); - lifecycle_manager + lifecycle_manager_client .trail(trail_id) .update_metadata(Some( "Lifecycle Stage: Maintained and Ready for Continued Use".to_string(), )) - .build_and_execute(&lifecycle_manager) + .build_and_execute(&lifecycle_manager_client) .await?; // --------------------------------------------------------------------- @@ -342,11 +363,15 @@ async fn main() -> Result<()> { // --------------------------------------------------------------------- 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?; + let on_chain_passport = manufacturer_client.trail(trail_id).get().await?; + let passport_records_page = manufacturer_client + .trail(trail_id) + .records() + .list_page(None, 20) + .await?; println!("Recorded DPP events:"); - for (sequence_number, record) in &first_page.records { + for (sequence_number, record) in &passport_records_page.records { println!( " #{} | tag={:?} | metadata={:?}", sequence_number, record.tag, record.metadata @@ -354,31 +379,32 @@ async fn main() -> Result<()> { } ensure!( - first_page.records.len() == 7, + passport_records_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"), + on_chain_passport.tags.tag_map.contains_key("maintenance") + && on_chain_passport.tags.tag_map.contains_key("recycling") + && on_chain_passport.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"), + on_chain_passport.roles.roles.contains_key("Manufacturer") + && on_chain_passport.roles.roles.contains_key("Distributor") + && on_chain_passport.roles.roles.contains_key("Consumer") + && on_chain_passport.roles.roles.contains_key("ServiceTechnician") + && on_chain_passport.roles.roles.contains_key("Recycler") + && on_chain_passport.roles.roles.contains_key("EPRO") + && on_chain_passport.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"), + on_chain_passport.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 + let maintenance_record = passport_records_page .records .iter() .find(|(_, record)| record.metadata.as_deref() == Some("event:annual_maintenance")); @@ -387,7 +413,7 @@ async fn main() -> Result<()> { "expected the maintenance record to be present in the DPP history" ); - let reward_record = first_page + let reward_record = passport_records_page .records .iter() .find(|(_, record)| record.metadata.as_deref() == Some("event:lcc_reward_distributed")); @@ -402,20 +428,20 @@ async fn main() -> Result<()> { } async fn issue_metadata_role( - client: &AuditTrailClient, + admin_client: &AuditTrailClient, trail_id: ObjectID, role_name: &str, issued_to: IotaAddress, ) -> Result<()> { - client + admin_client .trail(trail_id) .access() .for_role(role_name) .create(PermissionSet::metadata_admin_permissions(), None) - .build_and_execute(client) + .build_and_execute(admin_client) .await?; - client + admin_client .trail(trail_id) .access() .for_role(role_name) @@ -424,7 +450,7 @@ async fn issue_metadata_role( valid_from_ms: None, valid_until_ms: None, }) - .build_and_execute(client) + .build_and_execute(admin_client) .await?; Ok(()) diff --git a/examples/utils/utils.rs b/examples/utils/utils.rs index aba78a64..cbc4f4f1 100644 --- a/examples/utils/utils.rs +++ b/examples/utils/utils.rs @@ -63,8 +63,8 @@ pub async fn get_funded_audit_trail_client() -> Result Date: Wed, 29 Apr 2026 12:27:48 +0300 Subject: [PATCH 169/189] refactor: update batch delete functionality to skip locked records and improve documentation --- audit-trail-move/sources/audit_trail.move | 17 ++-- audit-trail-move/tests/locking_tests.move | 49 ++++++++---- audit-trail-move/tests/record_tests.move | 4 +- audit-trail-rs/src/core/records/mod.rs | 3 +- audit-trail-rs/src/core/records/operations.rs | 3 +- .../src/core/records/transactions.rs | 4 +- audit-trail-rs/tests/e2e/records.rs | 54 ++++++++++++- audit-trail-rs/tests/e2e/trail.rs | 2 +- .../examples/src/06_delete_records.ts | 77 ++++++++----------- .../examples/src/08_delete_audit_trail.ts | 17 ++-- examples/audit-trail/06_delete_records.rs | 8 +- examples/audit-trail/08_delete_audit_trail.rs | 6 +- 12 files changed, 157 insertions(+), 87 deletions(-) diff --git a/audit-trail-move/sources/audit_trail.move b/audit-trail-move/sources/audit_trail.move index 511e2b86..d211e04f 100644 --- a/audit-trail-move/sources/audit_trail.move +++ b/audit-trail-move/sources/audit_trail.move @@ -376,7 +376,7 @@ public fun delete_record( /// Delete up to `limit` records from the front of the trail. /// -/// Requires `DeleteAllRecords` permission. This operation bypasses record locks. +/// Requires `DeleteAllRecords` permission. Locked records are skipped. /// Returns the number of records deleted in this batch. public fun delete_records_batch( self: &mut AuditTrail, @@ -399,15 +399,22 @@ public fun delete_records_batch( let caller = ctx.sender(); let timestamp = clock.timestamp_ms(); let trail_id = self.id(); + let mut current = *linked_table::front(&self.records); + + while (deleted < limit && current.is_some()) { + let sequence_number = current.destroy_some(); + current = *linked_table::next(&self.records, sequence_number); + + if (self.is_record_locked(sequence_number, clock)) { + continue + }; - while (deleted < limit && !self.records.is_empty()) { - let next_sequence_number = option::destroy_some(*linked_table::front(&self.records)); assert_record_tag_allowed( self, cap, - record::tag(linked_table::borrow(&self.records, next_sequence_number)), + record::tag(linked_table::borrow(&self.records, sequence_number)), ); - let (sequence_number, record) = self.records.pop_front(); + let record = linked_table::remove(&mut self.records, sequence_number); if (record::tag(&record).is_some()) { record_tags::decrement_usage_count( diff --git a/audit-trail-move/tests/locking_tests.move b/audit-trail-move/tests/locking_tests.move index 5ccb4e14..0d998baa 100644 --- a/audit-trail-move/tests/locking_tests.move +++ b/audit-trail-move/tests/locking_tests.move @@ -948,20 +948,20 @@ fun test_time_based_locking_still_locked_before_expiry() { } #[test] -fun test_delete_records_batch_bypasses_record_lock() { +fun test_delete_records_batch_skips_locked_records() { let admin = @0xAD; let mut scenario = ts::begin(admin); { let locking_config = locking::new( - locking::window_time_based(3600), + locking::window_count_based(1), timelock::none(), timelock::none(), ); let (admin_cap, _) = setup_test_audit_trail( &mut scenario, locking_config, - std::option::some(record::new_text(string::utf8(b"Locked"))), + std::option::some(record::new_text(string::utf8(b"Record 0"))), ); transfer::public_transfer(admin_cap, admin); }; @@ -969,39 +969,62 @@ fun test_delete_records_batch_bypasses_record_lock() { ts::next_tx(&mut scenario, admin); { let (admin_cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); - let delete_all_role = string::utf8(b"DeleteAllRecordsAdmin"); - let delete_all_perms = permission::from_vec(vector[permission::delete_all_records()]); + let record_maintenance_role = string::utf8(b"RecordMaintenanceAdmin"); + let record_maintenance_perms = permission::from_vec(vector[ + permission::add_record(), + permission::delete_all_records(), + ]); trail .access_mut() .create_role( &admin_cap, - delete_all_role, - delete_all_perms, + record_maintenance_role, + record_maintenance_perms, std::option::none(), &clock, ts::ctx(&mut scenario), ); - let delete_all_cap = test_utils::new_capability_without_restrictions( + let record_maintenance_cap = test_utils::new_capability_without_restrictions( trail.access_mut(), &admin_cap, - &string::utf8(b"DeleteAllRecordsAdmin"), + &string::utf8(b"RecordMaintenanceAdmin"), + &clock, + ts::ctx(&mut scenario), + ); + + trail.add_record( + &record_maintenance_cap, + record::new_text(string::utf8(b"Record 1")), + std::option::none(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + trail.add_record( + &record_maintenance_cap, + record::new_text(string::utf8(b"Record 2")), + std::option::none(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); clock.set_for_testing(initial_time_for_testing() + 1000); let deleted = trail.delete_records_batch( - &delete_all_cap, + &record_maintenance_cap, 10, &clock, ts::ctx(&mut scenario), ); - assert!(deleted == 1, 0); - assert!(trail.record_count() == 0, 1); + assert!(deleted == 2, 0); + assert!(trail.record_count() == 1, 1); + assert!(!trail.has_record(0), 2); + assert!(!trail.has_record(1), 3); + assert!(trail.has_record(2), 4); - delete_all_cap.destroy_for_testing(); + record_maintenance_cap.destroy_for_testing(); cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); }; diff --git a/audit-trail-move/tests/record_tests.move b/audit-trail-move/tests/record_tests.move index e3be34f2..6ca662a9 100644 --- a/audit-trail-move/tests/record_tests.move +++ b/audit-trail-move/tests/record_tests.move @@ -250,7 +250,7 @@ fun test_delete_records_batch_with_matching_role_tags() { { let locking_config = locking::new( - locking::window_time_based(3600), + locking::window_none(), timelock::none(), timelock::none(), ); @@ -498,7 +498,7 @@ fun test_delete_records_batch_requires_matching_role_tags() { { let locking_config = locking::new( - locking::window_time_based(3600), + locking::window_none(), timelock::none(), timelock::none(), ); diff --git a/audit-trail-rs/src/core/records/mod.rs b/audit-trail-rs/src/core/records/mod.rs index 0e32ec6f..5353fecc 100644 --- a/audit-trail-rs/src/core/records/mod.rs +++ b/audit-trail-rs/src/core/records/mod.rs @@ -112,7 +112,8 @@ impl<'a, C, D> TrailRecords<'a, C, D> { /// Builds a transaction that deletes up to `limit` records in one operation. /// - /// Batch deletion removes records from the front of the trail and requires `DeleteAllRecords`. + /// Batch deletion requires `DeleteAllRecords`, skips locked records, and removes up to `limit` unlocked records + /// in trail order. pub fn delete_records_batch(&self, limit: u64) -> TransactionBuilder where C: AuditTrailFull + CoreClient, diff --git a/audit-trail-rs/src/core/records/operations.rs b/audit-trail-rs/src/core/records/operations.rs index 781e6909..471ce99f 100644 --- a/audit-trail-rs/src/core/records/operations.rs +++ b/audit-trail-rs/src/core/records/operations.rs @@ -113,7 +113,8 @@ impl RecordsOps { /// Builds the `delete_records_batch` call. /// - /// Batch deletion requires `DeleteAllRecords` and deletes from the front of the trail. + /// Batch deletion requires `DeleteAllRecords`, skips locked records, and deletes up to `limit` unlocked records + /// in trail order. pub(super) async fn delete_records_batch( client: &C, trail_id: ObjectID, diff --git a/audit-trail-rs/src/core/records/transactions.rs b/audit-trail-rs/src/core/records/transactions.rs index ab4c4a63..0e0f66bc 100644 --- a/audit-trail-rs/src/core/records/transactions.rs +++ b/audit-trail-rs/src/core/records/transactions.rs @@ -213,8 +213,8 @@ impl Transaction for DeleteRecord { /// Transaction that deletes multiple records in a batch operation. /// -/// The Move entry point deletes records from the front of the trail up to `limit` and reports the number of -/// deleted records through the emitted `RecordDeleted` events. +/// The Move entry point skips locked records, deletes up to `limit` unlocked records in trail order, and reports +/// the number of deleted records through the emitted `RecordDeleted` events. #[derive(Debug, Clone)] pub struct DeleteRecordsBatch { /// Trail object ID containing the records. diff --git a/audit-trail-rs/tests/e2e/records.rs b/audit-trail-rs/tests/e2e/records.rs index ea798b9c..5188b50b 100644 --- a/audit-trail-rs/tests/e2e/records.rs +++ b/audit-trail-rs/tests/e2e/records.rs @@ -964,7 +964,7 @@ async fn delete_records_batch_respects_limit_and_deletes_oldest_first() -> anyho let created = client .create_trail() .with_initial_record(InitialRecord::new(Data::text("batch-initial"), None, None)) - .with_locking_config(config_with_window(LockingWindow::TimeBased { seconds: 3600 })) + .with_locking_config(config_with_window(LockingWindow::None)) .finish() .build_and_execute(&client) .await? @@ -1020,6 +1020,58 @@ async fn delete_records_batch_respects_limit_and_deletes_oldest_first() -> anyho Ok(()) } +#[tokio::test] +async fn delete_records_batch_skips_locked_records() -> anyhow::Result<()> { + let client = get_funded_test_client().await?; + let created = client + .create_trail() + .with_initial_record(InitialRecord::new(Data::text("batch-skip-locked-initial"), None, None)) + .with_locking_config(config_with_window(LockingWindow::CountBased { count: 1 })) + .finish() + .build_and_execute(&client) + .await? + .output; + + let trail_id = created.trail_id; + let records = client.trail(trail_id).records(); + + grant_role_capability( + &client, + trail_id, + "BatchRecordMaintenance", + [Permission::AddRecord, Permission::DeleteAllRecords], + ) + .await?; + + records + .add(Data::text("batch-skip-locked-second"), None, None) + .build_and_execute(&client) + .await?; + records + .add(Data::text("batch-skip-locked-third"), None, None) + .build_and_execute(&client) + .await?; + + let deleted = records + .delete_records_batch(10) + .build_and_execute(&client) + .await? + .output; + assert_eq!(deleted, 2, "batch delete should skip the count-locked tail record"); + assert_eq!(records.record_count().await?, 1); + assert!( + records.get(0).await.is_err(), + "oldest unlocked record should be deleted" + ); + assert!( + records.get(1).await.is_err(), + "second unlocked record should be deleted" + ); + assert_text_data(records.get(2).await?.data, "batch-skip-locked-third"); + + Ok(()) +} + #[tokio::test] async fn delete_records_batch_requires_delete_all_records_permission() -> anyhow::Result<()> { let admin = get_funded_test_client().await?; diff --git a/audit-trail-rs/tests/e2e/trail.rs b/audit-trail-rs/tests/e2e/trail.rs index 4ade501b..def92a5b 100644 --- a/audit-trail-rs/tests/e2e/trail.rs +++ b/audit-trail-rs/tests/e2e/trail.rs @@ -450,7 +450,7 @@ async fn delete_records_batch_then_delete_audit_trail_roundtrip() -> anyhow::Res let created = client .create_trail() .with_initial_record(InitialRecord::new(Data::text("trail-batch-delete-e2e"), None, None)) - .with_locking_config(config_with_window(LockingWindow::TimeBased { seconds: 3600 })) + .with_locking_config(config_with_window(LockingWindow::None)) .finish() .build_and_execute(&client) .await? diff --git a/bindings/wasm/audit_trail_wasm/examples/src/06_delete_records.ts b/bindings/wasm/audit_trail_wasm/examples/src/06_delete_records.ts index 91c0058b..cf0ba894 100644 --- a/bindings/wasm/audit_trail_wasm/examples/src/06_delete_records.ts +++ b/bindings/wasm/audit_trail_wasm/examples/src/06_delete_records.ts @@ -5,43 +5,29 @@ * ## Actors * * - **Admin client**: Creates the trail and sets up the RecordMaintenance role. - * - **Record maintainer client**: Holds the RecordMaintenance capability. Adds records and then + * - **Maintenance admin client**: Holds the RecordMaintenance capability. Adds records and then * deletes them individually and in batch. * * Demonstrates how to: - * 1. Create records via a delegated RecordMaintenance role. + * 1. Create records using a delegated record-maintenance role. * 2. Delete a single record by sequence number. - * 3. Batch-delete remaining records. + * 3. Delete the remaining records in one batch. */ -import { - CapabilityIssueOptions, - Data, - LockingConfig, - LockingWindow, - Permission, - PermissionSet, - TimeLock, -} from "@iota/audit-trail/node"; +import { CapabilityIssueOptions, Data, Permission, PermissionSet } from "@iota/audit-trail/node"; import { strict as assert } from "assert"; import { getFundedClient, TEST_GAS_BUDGET } from "./util"; export async function deleteRecords(): Promise { console.log("=== Audit Trail: Delete Records ===\n"); - // `adminClient` creates the trail and delegates record maintenance. - // `recordMaintainerClient` adds and deletes records. + // Use a maintenance client to show deletes happening through a delegated capability. const adminClient = await getFundedClient(); - const recordMaintainerClient = await getFundedClient(); + const maintenanceAdminClient = await getFundedClient(); const { output: createdTrail } = await adminClient .createTrail() - .withTrailMetadata("Delete Records Example", "Trail configured to demonstrate record deletions") - .withUpdatableMetadata("Status: Active") - .withLockingConfig( - new LockingConfig(LockingWindow.withNone(), TimeLock.withNone(), TimeLock.withNone()), - ) - .withInitialRecordString("Seed record", "v0") + .withInitialRecordString("Initial record", "event:created") .finish() .withGasBudget(TEST_GAS_BUDGET) .buildAndExecute(adminClient); @@ -54,48 +40,51 @@ export async function deleteRecords(): Promise { .withGasBudget(TEST_GAS_BUDGET) .buildAndExecute(adminClient); await recordMaintenanceRole - .issueCapability(new CapabilityIssueOptions(recordMaintainerClient.senderAddress())) + .issueCapability(new CapabilityIssueOptions(maintenanceAdminClient.senderAddress())) .withGasBudget(TEST_GAS_BUDGET) .buildAndExecute(adminClient); - const maintenanceRecords = recordMaintainerClient.trail(trailId).records(); + const maintenanceRecords = maintenanceAdminClient.trail(trailId).records(); - // RecordMaintainer adds records. - const firstMaintainedRecord = await maintenanceRecords - .add(Data.fromString("First record"), "v1") + const firstAddedRecord = await maintenanceRecords + .add(Data.fromString("Second record"), "event:received") .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(recordMaintainerClient); - const secondMaintainedRecord = await maintenanceRecords - .add(Data.fromString("Second record"), "v2") + .buildAndExecute(maintenanceAdminClient); + const secondAddedRecord = await maintenanceRecords + .add(Data.fromString("Third record"), "event:dispatched") .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(recordMaintainerClient); + .buildAndExecute(maintenanceAdminClient); console.log( - "Added records", - firstMaintainedRecord.output.sequenceNumber, - "and", - secondMaintainedRecord.output.sequenceNumber, + "Trail has records at sequence numbers 0,", + firstAddedRecord.output.sequenceNumber, + ",", + secondAddedRecord.output.sequenceNumber, ); + assert.equal(await maintenanceRecords.recordCount(), 3n); - // Delete a single record. const deletedRecord = await maintenanceRecords - .delete(firstMaintainedRecord.output.sequenceNumber) + .delete(firstAddedRecord.output.sequenceNumber) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(recordMaintainerClient); + .buildAndExecute(maintenanceAdminClient); console.log("Deleted record", deletedRecord.output.sequenceNumber); let recordCount = await maintenanceRecords.recordCount(); console.log("Record count after single delete:", recordCount); - assert.equal(recordCount, 2n); // seed + secondMaintainedRecord + assert.equal(recordCount, 2n); + await assert.rejects( + () => maintenanceRecords.get(firstAddedRecord.output.sequenceNumber), + "deleted record should no longer be readable", + ); - // Batch-delete remaining records. - const batchDeletedRecords = await maintenanceRecords + // Batch delete skips locked records and returns how many records were removed. + const deletedCount = await maintenanceRecords .deleteBatch(BigInt(10)) .withGasBudget(TEST_GAS_BUDGET) - .buildAndExecute(recordMaintainerClient); - console.log("Batch deleted", batchDeletedRecords.output, "records"); + .buildAndExecute(maintenanceAdminClient); + console.log("Batch deleted", deletedCount.output, "remaining records."); + assert.equal(deletedCount.output, 2n); recordCount = await maintenanceRecords.recordCount(); - assert.equal(recordCount, 0n, "all records should be deleted after batch"); - console.log("Record count after batch delete:", recordCount); + assert.equal(recordCount, 0n); } diff --git a/bindings/wasm/audit_trail_wasm/examples/src/08_delete_audit_trail.ts b/bindings/wasm/audit_trail_wasm/examples/src/08_delete_audit_trail.ts index 8cd2ab56..f450408d 100644 --- a/bindings/wasm/audit_trail_wasm/examples/src/08_delete_audit_trail.ts +++ b/bindings/wasm/audit_trail_wasm/examples/src/08_delete_audit_trail.ts @@ -5,8 +5,8 @@ * ## Actors * * - **Admin client**: Creates the trail and sets up the MaintenanceAdmin role. - * - **Maintenance admin client**: Holds delete permissions. Attempts (and fails) to delete the - * non-empty trail, then batch-deletes all records before removing the trail itself. + * - **Maintenance admin client**: Holds delete permissions. Attempts (and fails) to delete the non-empty trail, then + * batch-deletes all records before removing the trail itself. * * Demonstrates how to: * 1. Show that a non-empty trail cannot be deleted. @@ -21,8 +21,7 @@ import { getFundedClient, TEST_GAS_BUDGET } from "./util"; export async function deleteAuditTrail(): Promise { console.log("=== Audit Trail: Delete Trail ===\n"); - // `adminClient` creates the trail and delegates trail maintenance. - // `maintenanceAdminClient` empties and deletes the trail. + // Use a maintenance client to keep deletion permissions separate from trail creation. const adminClient = await getFundedClient(); const maintenanceAdminClient = await getFundedClient(); @@ -47,7 +46,6 @@ export async function deleteAuditTrail(): Promise { const maintenanceTrail = maintenanceAdminClient.trail(trailId); - // 1. Attempting to delete a non-empty trail should fail. let deleteWhileNonEmptySucceeded = false; try { await maintenanceTrail @@ -61,18 +59,17 @@ export async function deleteAuditTrail(): Promise { assert.equal(deleteWhileNonEmptySucceeded, false, "a trail must be empty before deletion"); console.log("Deleting the non-empty trail failed as expected.\n"); - // 2. Batch-delete all records. - const deletedRecords = await maintenanceTrail + // Batch delete skips locked records and returns how many records were removed before trail deletion. + const deletedCount = await maintenanceTrail .records() .deleteBatch(BigInt(10)) .withGasBudget(TEST_GAS_BUDGET) .buildAndExecute(maintenanceAdminClient); - console.log("Deleted", deletedRecords.output, "record(s) before trail removal.\n"); + console.log("Deleted", deletedCount.output, "record before trail removal.\n"); const count = await maintenanceTrail.records().recordCount(); - assert.equal(count, 0n, "trail should have no records after batch delete"); + assert.equal(count, 0n); - // 3. Delete the now-empty trail. const deletedTrail = await maintenanceTrail .deleteAuditTrail() .withGasBudget(TEST_GAS_BUDGET) diff --git a/examples/audit-trail/06_delete_records.rs b/examples/audit-trail/06_delete_records.rs index 9f4f8194..7b095a3e 100644 --- a/examples/audit-trail/06_delete_records.rs +++ b/examples/audit-trail/06_delete_records.rs @@ -105,15 +105,15 @@ async fn main() -> Result<()> { "deleted record should no longer be readable" ); - // Batch delete returns the exact sequence numbers that were removed. - let deleted_sequence_numbers = maintenance_records + // Batch delete skips locked records and returns how many records were removed. + let deleted_count = maintenance_records .delete_records_batch(10) .build_and_execute(&maintenance_admin_client) .await? .output; - println!("Batch deleted the remaining records: {deleted_sequence_numbers:?}."); - ensure!(deleted_sequence_numbers == vec![0, second_added_record.sequence_number]); + println!("Batch deleted {deleted_count} remaining records."); + ensure!(deleted_count == 2); ensure!(maintenance_records.record_count().await? == 0); Ok(()) diff --git a/examples/audit-trail/08_delete_audit_trail.rs b/examples/audit-trail/08_delete_audit_trail.rs index 7b90029a..8c6f91d2 100644 --- a/examples/audit-trail/08_delete_audit_trail.rs +++ b/examples/audit-trail/08_delete_audit_trail.rs @@ -74,14 +74,14 @@ async fn main() -> Result<()> { ensure!(delete_while_non_empty.is_err(), "a trail must be empty before deletion"); println!("Deleting the non-empty trail failed as expected.\n"); - // Batch delete returns the exact sequence numbers removed before trail deletion. - let deleted_sequence_numbers = maintenance_trail + // Batch delete skips locked records and returns how many records were removed before trail deletion. + let deleted_count = maintenance_trail .records() .delete_records_batch(10) .build_and_execute(&maintenance_admin_client) .await? .output; - println!("Deleted records {deleted_sequence_numbers:?} before trail removal.\n"); + println!("Deleted {deleted_count} record before trail removal.\n"); ensure!(maintenance_trail.records().record_count().await? == 0); From 59c98de21478cc7e2259a23b00147daaee1a76c0 Mon Sep 17 00:00:00 2001 From: Yasir Date: Wed, 29 Apr 2026 13:32:14 +0300 Subject: [PATCH 170/189] Simplify TfComponents package resolution --- audit-trail-rs/src/package.rs | 67 ++++++++++++++++++++++------------- 1 file changed, 43 insertions(+), 24 deletions(-) diff --git a/audit-trail-rs/src/package.rs b/audit-trail-rs/src/package.rs index 0d443bb7..612fc823 100644 --- a/audit-trail-rs/src/package.rs +++ b/audit-trail-rs/src/package.rs @@ -34,10 +34,6 @@ static AUDIT_TRAIL_PACKAGE_REGISTRY: LazyLock> = LazyLoc ) }); -/// Runtime overrides for TfComponents package information. -static TF_COMPONENTS_OVERRIDE_REGISTRY: LazyLock> = - LazyLock::new(|| RwLock::new(PackageRegistry::default())); - /// Returns a read lock to the package registry. pub(crate) async fn audit_trail_package_registry() -> PackageRegistryLock { AUDIT_TRAIL_PACKAGE_REGISTRY.read().await @@ -68,10 +64,6 @@ pub(crate) fn blocking_audit_trail_registry_mut() -> PackageRegistryLockMut { AUDIT_TRAIL_PACKAGE_REGISTRY.blocking_write() } -pub(crate) async fn tf_components_override_registry_mut() -> PackageRegistryLockMut { - TF_COMPONENTS_OVERRIDE_REGISTRY.write().await -} - #[derive(Debug, Clone, Copy)] pub(crate) struct ResolvedPackageIds { pub audit_trail_package_id: ObjectID, @@ -104,23 +96,20 @@ pub(crate) async fn resolve_package_ids( drop(package_registry); - let env = Env::new_with_alias(chain_id.clone(), resolved_network.as_ref()); if let Some(audit_trail_package_id) = package_overrides.audit_trail { + let env = Env::new_with_alias(chain_id.clone(), resolved_network.as_ref()); audit_trail_package_registry_mut() .await - .insert_env_history(env.clone(), vec![audit_trail_package_id]); - } - if let Some(tf_components_package_id) = package_overrides.tf_component { - tf_components_override_registry_mut() - .await - .insert_env_history(env, vec![tf_components_package_id]); + .insert_env_history(env, vec![audit_trail_package_id]); } - - let tf_components_package_id = resolve_tf_components_package_id(resolved_network.as_ref()).await.ok_or_else(|| { - Error::InvalidConfig(format!( - "no information for a published `TfComponents` package on network {network}; try to use `AuditTrailClientReadOnly::new_with_package_overrides`" - )) - })?; + let tf_components_package_id = package_overrides + .tf_component + .or_else(|| tf_components_registry::tf_components_package_id(resolved_network.as_ref())) + .ok_or_else(|| { + Error::InvalidConfig(format!( + "no information for a published `TfComponents` package on network {network}; try to use `AuditTrailClientReadOnly::new_with_package_overrides`" + )) + })?; Ok(( resolved_network, @@ -131,7 +120,37 @@ pub(crate) async fn resolve_package_ids( )) } -pub(crate) async fn resolve_tf_components_package_id(network: &str) -> Option { - let override_package_id = TF_COMPONENTS_OVERRIDE_REGISTRY.read().await.package_id(network); - override_package_id.or_else(|| tf_components_registry::tf_components_package_id(network)) +#[cfg(test)] +mod tests { + + use super::*; + + #[tokio::test] + async fn resolves_tf_components_package_id() { + let network = NetworkName::try_from("testnet").expect("valid network"); + let registry_package_id = tf_components_registry::tf_components_package_id("testnet") + .expect("testnet TfComponents package is in the registry"); + let override_package_id = ObjectID::random(); + + let (_, registry_resolved_package_ids) = resolve_package_ids(&network, &PackageOverrides::default()) + .await + .expect("registered package IDs are valid"); + + assert_eq!( + registry_resolved_package_ids.tf_components_package_id, + registry_package_id + ); + + let (_, resolved_package_ids) = resolve_package_ids( + &network, + &PackageOverrides { + audit_trail: Some(ObjectID::random()), + tf_component: Some(override_package_id), + }, + ) + .await + .expect("explicit package overrides are valid"); + + assert_eq!(resolved_package_ids.tf_components_package_id, override_package_id); + } } From 6d0ae1862893128fb41f0a3ce1ce2cf1b3ffb96b Mon Sep 17 00:00:00 2001 From: Yasir Date: Thu, 30 Apr 2026 10:58:35 +0300 Subject: [PATCH 171/189] chore: Refactor type imports for improved readability in trail and types modules --- bindings/wasm/audit_trail_wasm/src/trail.rs | 5 ++--- bindings/wasm/audit_trail_wasm/src/types.rs | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/bindings/wasm/audit_trail_wasm/src/trail.rs b/bindings/wasm/audit_trail_wasm/src/trail.rs index 4df45227..d1a5cb15 100644 --- a/bindings/wasm/audit_trail_wasm/src/trail.rs +++ b/bindings/wasm/audit_trail_wasm/src/trail.rs @@ -26,9 +26,8 @@ use wasm_bindgen::prelude::*; use crate::builder::WasmAuditTrailBuilder; use crate::types::{ WasmAuditTrailDeleted, WasmCapabilityDestroyed, WasmCapabilityIssued, WasmCapabilityRevoked, WasmEmpty, - WasmImmutableMetadata, WasmLinkedTable, WasmLockingConfig, WasmRecordAdded, WasmRecordDeleted, - WasmRecordTagEntry, WasmRevokedCapabilitiesCleanedUp, WasmRoleCreated, WasmRoleDeleted, WasmRoleMap, - WasmRoleUpdated, + WasmImmutableMetadata, WasmLinkedTable, WasmLockingConfig, WasmRecordAdded, WasmRecordDeleted, WasmRecordTagEntry, + WasmRevokedCapabilitiesCleanedUp, WasmRoleCreated, WasmRoleDeleted, WasmRoleMap, WasmRoleUpdated, }; /// Read-only view of an on-chain audit trail for wasm consumers. diff --git a/bindings/wasm/audit_trail_wasm/src/types.rs b/bindings/wasm/audit_trail_wasm/src/types.rs index 5e62ed1d..bf3fbeb3 100644 --- a/bindings/wasm/audit_trail_wasm/src/types.rs +++ b/bindings/wasm/audit_trail_wasm/src/types.rs @@ -7,8 +7,8 @@ use audit_trail::core::types::{ AuditTrailCreated, AuditTrailDeleted, Capability, CapabilityAdminPermissions, CapabilityDestroyed, CapabilityIssueOptions, CapabilityIssued, CapabilityRevoked, Data, ImmutableMetadata, LockingConfig, LockingWindow, PaginatedRecord, Permission, PermissionSet, Record, RecordAdded, RecordCorrection, RecordDeleted, - RevokedCapabilitiesCleanedUp, Role, - RoleAdminPermissions, RoleCreated, RoleDeleted, RoleMap, RoleTags, RoleUpdated, TimeLock, + RevokedCapabilitiesCleanedUp, Role, RoleAdminPermissions, RoleCreated, RoleDeleted, RoleMap, RoleTags, RoleUpdated, + TimeLock, }; use iota_interaction::types::base_types::ObjectID; use iota_interaction::types::collection_types::LinkedTable; From 6e879f666f569a57ef3156f366e769dd566c13b6 Mon Sep 17 00:00:00 2001 From: Yasir Date: Thu, 30 Apr 2026 11:26:27 +0300 Subject: [PATCH 172/189] chore: Simplify import statements in audit_trail.move for better readability --- audit-trail-move/sources/audit_trail.move | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/audit-trail-move/sources/audit_trail.move b/audit-trail-move/sources/audit_trail.move index 138d86e0..076f0e65 100644 --- a/audit-trail-move/sources/audit_trail.move +++ b/audit-trail-move/sources/audit_trail.move @@ -23,11 +23,7 @@ use audit_trail::{ }; use iota::{clock::{Self, Clock}, event, linked_table::{Self, LinkedTable}, vec_set::VecSet}; use std::string::String; -use tf_components::{ - capability::Capability, - role_map::{Self, RoleMap}, - timelock::TimeLock -}; +use tf_components::{capability::Capability, role_map::{Self, RoleMap}, timelock::TimeLock}; // ===== Errors ===== #[error] From 951312c04a1b81bdbf958d7ea6c41e87b60882bd Mon Sep 17 00:00:00 2001 From: Yasir Date: Thu, 30 Apr 2026 14:44:38 +0300 Subject: [PATCH 173/189] docs: add missing audit trail TS snippets --- .../examples/src/02_add_and_read_records.ts | 4 ++++ .../examples/src/advanced/11_manage_record_tags.ts | 11 +++++++---- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/bindings/wasm/audit_trail_wasm/examples/src/02_add_and_read_records.ts b/bindings/wasm/audit_trail_wasm/examples/src/02_add_and_read_records.ts index 85def291..8dff158e 100644 --- a/bindings/wasm/audit_trail_wasm/examples/src/02_add_and_read_records.ts +++ b/bindings/wasm/audit_trail_wasm/examples/src/02_add_and_read_records.ts @@ -58,6 +58,10 @@ export async function addAndReadRecords(): Promise { assert.equal(seedRecord.data.toString(), "seed record"); assert.equal(secondRecord.data.toString(), "record 2"); + const count = await recordAdminRecords.recordCount(); + console.log(`Current record count: ${count}`); + assert.equal(count, 3n, `expected 3 records, got ${count}`); + // Pagination uses the previous page cursor to continue from the next record. const firstPage = await recordAdminRecords.listPage(undefined, 2); const secondPage = await recordAdminRecords.listPage(firstPage.nextCursor, 2); diff --git a/bindings/wasm/audit_trail_wasm/examples/src/advanced/11_manage_record_tags.ts b/bindings/wasm/audit_trail_wasm/examples/src/advanced/11_manage_record_tags.ts index 1c569081..986ee54d 100644 --- a/bindings/wasm/audit_trail_wasm/examples/src/advanced/11_manage_record_tags.ts +++ b/bindings/wasm/audit_trail_wasm/examples/src/advanced/11_manage_record_tags.ts @@ -96,10 +96,13 @@ export async function manageRecordTags(): Promise { } assert.equal(removeFinanceSucceeded, false, "a tag referenced by a role or record must not be removable"); - // TagAdmin removes "legal" tag — should succeed because nothing uses it. - await tagAdminClient.trail(trailId).tags().remove("legal").withGasBudget(TEST_GAS_BUDGET).buildAndExecute( - tagAdminClient, - ); + // TagAdmin removes "legal" tag because nothing uses it. + await tagAdminClient + .trail(trailId) + .tags() + .remove("legal") + .withGasBudget(TEST_GAS_BUDGET) + .buildAndExecute(tagAdminClient); onChainTrail = await adminClient.trail(trailId).get(); console.log("Registry after removing \"legal\":", onChainTrail.tags.map((t) => t.tag), "\n"); From 0b7efbd93e89c6b11d2900040157fff8395f7206 Mon Sep 17 00:00:00 2001 From: Yasir Date: Fri, 1 May 2026 11:50:53 +0300 Subject: [PATCH 174/189] docs: update README files for IOTA Notarization Suite and add new banner image --- .github/banner_audit_trails.png | Bin 0 -> 104100 bytes README.md | 102 ++++++------ audit-trail-rs/README.md | 267 ++++++++++++++++++++++++++++---- notarization-rs/README.md | 63 ++++---- 4 files changed, 323 insertions(+), 109 deletions(-) create mode 100644 .github/banner_audit_trails.png diff --git a/.github/banner_audit_trails.png b/.github/banner_audit_trails.png new file mode 100644 index 0000000000000000000000000000000000000000..fac890625b46a1930cd30571b66f777833373d54 GIT binary patch literal 104100 zcmV)VK(D`vP)&S(vZVueR28t2e5lMdhlMqIDs!IF#6N^SsH;TPlv~K z+Q%cLYQ>w)`Q@V@%`YG0qX+ML`t11DZ$->uI{(W`V;I^$k`DI$rH{Gq#?zi5`=fRl z8~@hi`<6cW(Qa_a=N~h9t~Z8n2ME5j~P07e%I|w zx`Avo8tNE!F(xpF&$eKWRR)b~>2krX6fXFmL1g)8sI>zPm68Y0Vbx#rUfiL0Etw`_ zEj0$IoksJnp+KFki8LN}-68f}_^~5>x`SQo+9gJ>b+YswVuohZrU7HD^d+Yq-LUIg zg|>t+v`6|~8raH*Kev20g3yinu_oeZrf=yq{2kITnT9Vby@lUu{w!lh1nhc%A-vO6 z(wOU10VgV;iOKVf@p;4C+K*mw_~FxAvZf#gL;GPIJ~W%2GXw5Ycn-1OIMrGsKwk9LTK&KPB)g5;995#KX9y(X=ICbb=9SkkTkk*dc6ZKBojn z`mwFS7kLS&`gKzA-!&lisV*{*sY~#wLd}bWWrk)J(@4yaLpezoafE;cOf>&1CfKSS zvySAtz`)PaS&Pc_bYLcvG>efwF}KMdO>2zY^<&*z##E*27|c+-1J67wQ?-G5#lyxr z*I*6zDs3(IZZWThJIGmRb^ln-1fy80&gsbdt>+&C!JqewUn0JD4}R#~39&nq=rMxU#=MqoY>s#*m*HqofzuqP z4RjZ2{Iol`u&-gnOLqtMs%`LY9KP|u;T&(Az9nfh3u(_tx}q{rn=3B{vyGc>_ZKTT zh|TOQO_=%5IJ+A!FENULaoY3L?TYiWgfFKq&uoDlbG$DJ2ZZDV+RD(err#vFej5Xd zc*lQr7G?S<{N~qd!&v@bq!9UJ*QR*tVGgJmgR|7n zZzUDkZtGMg=VEC(cJ6uHMgEY5Pn9lc#u{urbJF%VS_F2bztyT%{7yF9S^_33e|;$V zfkw1LvL9$3>lL2MNzY;oXy8A$*gB7<97&7xYt#mZ=N{9r(ONhMx4p%$5e;i`;gp^^ z$Xn3~O|urY%1f_GHGu~#qEIm$>03?O8&BtMA%6u8Iyg@!CU9L4C|NUy zO-IJXDqSzo!*5~i=rcNviL`3vdrs>YQi4Eb;#eB0G*oG)(d7;}GJN4Ki(^s$Eq+(Z)F;cC8m?`TNT+C%Mc_|?bN5-Qi41Ut(dAbwM&+*n}E4P?9Hu|6S2iA&81;cF-Q?ZR=_gH zbb2x#=q9lm*8B6C28T}%!%g2Wx52j_M-zk6Mbe#JI!qh=VuqB*7Q+-&>Z%-0w%;s^ zJwq5ax4Ik8_Lm_C*6Hx#aO)`<-)Q2Mu>)3Vtn%+1KldFx5)bR!k-Bo4;4{!?etMj^ zd5l{D{niF$NULnd(wXA%1aB_cKSR4wv@67lpJTqXl(a>&WA|b|cXPr&Wg+$DuvzIa zgnTYN600nAFZSKC1@aDQnvw(7Lnd;ACJ=lcF%IjMEcDFx-i3EXUNt21q^#bJZaAWJ zP2pYFJxdj7e@=qRDjI8&kDblnm%IiR`)ZA$sU%CIOi{cC>IS=`%BwfFYC3-fb=!W# zj5sr<2eHK?>0!7w`O_@u!;Z3vW~V1&$NH-a&B2yUUB`hX$-fxR%u>E501X73sy$(- zWDD@o`o=8;E=C38#*pXZ^qLTkM$vVn>=0J4#;m%i}TQJFjJir|oX( zFF$ztz?j^b8W`?HPoM2065Fu z(k z4zp=!ZG{Rit{hXHBuoo=TN)*E%#_+SvvoH)jIJ6rsaKYfrk8prjz3!lDt#*LD!bKb zI$x*@1>#dO)t7q{QTyVea}sI$7)ODYa<-mcpein~hKwAO@+1*+BrWC^%w^$4toO^0 z(<$Bc`e~@QOSl$H+@Oswyk4Iz@zuOUOn2keLscH3@u!9fEo>q?wh_(KIFdh}=D4%A zi)l+fQ(U|L#;$yKG){_kv$y*(6?!1n1d*~=0(>kIYcTA16z zW_`J2iJV@UCPV{pjs1)?&M7}N$fnAm5rLe(LmWjZ*0nf-BlcV78m+Vw!GJF_1@Jc` zke)3LEnV!?MoycSMoNB3c-H;3Q(W`RUE(I+y7A?YITCHzi<#0=$WHSTd|OkhaApiu zdMwbqGH4{y7$w>v$t$Z|vNSZ)BG1RlWU&!5Sq`SU=6D&m#L7DocNHB#(zq38H2IX3 zfua?LHH3Sdv2m6)Fqwu$H+^*-HlV_}$?1@H@wS+@T^&XGLw?d7habXv^>jor zw7r#GYRi`%i?nycdVT$cbQ}KzYNa8@&PT}U&GjSNge?N)KP+C!;|9#I(Mj=ZgKE}- zft=xAyNoGGlV!4i<-b(Ypbc3QXtUYeJWfO=sM4s&KpH0@UOP(qtp;3IjDKUyFf*nq z9b?AU#9-d#4?7)UJ4a{W$D=#gTur0-0B4r{lXYk-lq8Rr(Hbix)#Y z46F6clc2vtmoEo2N9aNMsf462oskJvbre5T4PlI;|~j!rvNGm7a*}{@N*q zDt(t2s+>T*$EMjbJS7d0ST-$huWoo?mheXcPX2=w(^xv8v1Lq3+R7!!DGF6Oi$z}D ziONA@EFUL>etJoCYi#XMnJcWW zKlN#v1Hm7)FV^o@H=phue#O9t($Nh{@sx&Qk6@fmq(8W z4O#x`=8L_Fehl<_x^{fhU|C%Fmu&-F)T=9;vdUbr13O2mccb2 z<38*;6_>9++BR7rFPslWpPOIB5@EPQ7l2>U>bzu zakZNe$A=D@8n?b>-gQPo!WfIOW@HeGA`ez)LW zU+%v&_rk*GLxh`pYg#AG87n6qG;6P7?pl#;>H;pA0_x{^q?j*VDuxJe1hAHj~q$EJ_kb@GC+(E z+gKp=F2k{S+kV74HGDEA~O54#eN|YQ8A8xNc3iJsReJnOE zNinSVFkve0IMmbU_^U7zuk_>D=`fQ-l>f!q@$(O>tB;`@AoFI;rGm!8=KAC1(HmbL zlFucqu6;}6X~t%cPNz*v37$PX4;WRb2l)(+!shE)xV`$o^GW7K-ZdW9SD)-1zplJv z>U>_L*o^_TG{K$6-RK3Avk_%l8jX-h%XUM0BentJ$&W zwN^=M7e%KGnSwAOKubgX=Hgeb2^qd^XHynie_MyMUx1p#w{+4fOE zw}z5xDc_xNS!YCYD-PZAXu0SSQ z%DKIHf^KN=joO}80^*4JMI!PUo6 z)`~;X$@ta@uKsXw@WQwRe!c-`Q4#M*>-7!GD-$vqi!)`cqiw%DTChB~aGqz3l_Ap1 zw@;@%h+$1I(}QXN2tAYD?f<@dayPyT6DEw6U#(qB zKLXxOc=vjxEvBViE&jL1xMTS*C8qgYx4M1eoHxQJBfBkW+=Cd0N=Ty{WncczOQN^of?-IewORImT8@qkhD)UHRTb z95ClK#m~+|eQ?t@=Ol}6*I0o8M^ysTPcG}ntG~?n`r?<+EtdxmBjWl_SG{gyh8I^9 zj2H!I>_<%baoFVgh#}7#md=%UDbE!|9oE;MU)x<2&nX1@ykMAVj9uVMYF8Do zrGdWOs+-W)H(y+ze>786&5sX`UfbP4+eDe3S9+8Uw|(;JcBWG=Nd8=bw0xD zix1#=hlAs<1nNzX$LZF8`Lr1i&YN|8@&05Hs(y|!Qwm;Q9lrFNXqmX-YNKZ|&GBFj zlp;N0%EUAkijLFk>rZdaf0aCosZ1(QDTmf>9tWFXATCqO6S+CIm&tsY{hd-jiB1MG8IG#%p@ z$D1@|`3MuT6jT83JRE0Gv4v?pPE#NX40-(+zLek?^8Vh@YuPu={+xog^a(uYAl$t? zco16J!yN<8s-~SWlOJM5G*$(URZSa5J%0xlO!Iad8_lnMt^=o?;94HO)?weqey%^2 z#`C8=zIjY@DM(&keRO**@;u0*s9BIo#b1+k>Nj|%dU5aQE3FT;jZu2MuL&jt&CZ+| z#^244M3m2lja&Fy-+rzowND5tp0}wnQd5>B$7wI_AAeOyn!P!J6|@1)DpvJQ^2?75 z!(n}M@$1#~CopTU751JgE%g-cqFE&1=nxJzki*X*=fnZgW*ZBQt3u!qdZO_ie{TyHjdOgC~2ikN*gPNMy#7kp84~7H=o{Ked;%jQd7=N zFYC{fi%iLbmz{^kYhr{MjD;Cv)rD|^!X`Y6072lJFK@3uby5ND7;p}O+%#bC;H5yH zJL~Tmi2PQBsZkVm`hRhI^SK||!rNS;;`y=lzo|HCSNEge+kbI!@NitWJ?A!>5HtyG z)>n_Tr7&w8ZHe2f1a1X+Z~rB2=`&U{(wO<#&neJAo9oYndQ#n(lH-7PL&*(Hr?40P z?Cn-QnL)Q_9E00h;N{iL=eO6NO=`xiP+%)3I58gN)eGsejzWfSk7v(-r%`P{qiS3< zArIs7M4@|s-`el9zy3JhVW~J#ipU~F6^@iwJeW8E@haf()#ZWXH2Kz|f~CCPF+#u$ z^350mRN>F&*40Ph>+`|)dDp<6!fwQv*fI8)Z3em?R+sDb6My$(3Ay2Y^zx7@W|d&R z)sdrJD{L1p_U|v2_q)Y$*YAm0Q@orOQpZB+{L9FJb%B{+M_$aKq@+JLyLQtaT3@Z# z=j+?2usz_60wbM=RJ^{}bCf*kx+CnD-T=ZoiOHE3;hlmT1GHHU!}WS~?yn~;zh_g{ zNAuDuekLg8E%$!!WU)N$@t|AmdEa4K67M#Ef*{{oW^5;%-6scmdMXxrZp(N69>|cwIaP22#ik9T;pG*Vv68dgpiRNgC9xXvGZ<4197sY`oyw+@2ec8Z{u$?v!oB6xI0B z@yYU}?+&3`uq8Af4!2v=VMmbdp^dXxvm=$QUH%cTXf|2KoT%J>_ zQd>*&OC&}4ou7;p+WLYqbi$=7bCvw5B*okV)z zR6sN~^8T1^h?boiz;&5?YVEmx2o!LV>e6Yz_3fjILADz7?PK24@>8F`|$)6w}-t!IZ~@as~}bW7^)9EIF9N zZ!!Vq>3lC>>E_*PUp7cJp4CK-CcE!$RYMN>dDK$Yw2g0f0QEeunxj>4QVuubdY5X! zhub}H+k{24uC0RXnbS7x)^#`{h_`kCj^xuWowrMv)81!KkM&oIci02b;-86KUs&0C zlXXEWXC}#!b|wFK*cH$O($58_fNH?^|HcSo`*sa%z8Z|UvX2BVS zC%Xx20OJ@NlRyA7B>MQ`BFevprvqDsN6^E%nAf+tU1s6ScDn4)QVAbN5}22@_=ADp z3>|Et*I2_9a~$ghDR-wZ(L1KzY*}23B(rAbX{qVu?^fdoYOiydZsJ__INgClC{^LM z;nMUNO}+tl@30xccGAQso+${n*7cX+OAWiIhen_76vK|y?Sj|ba@aIwunp;1u%+=D z=b_1|StM^7)>~uBx}_M_jcrR!{O>{{nre2j)3RBNwm}}opEG1NC}SF;9ab=Eoydym z21Ux@XJl1ES3@Ay*fzYHIi@cJ|4mJ+8512E&(G;!<3=N*-K4Fja6&Pdu$zyt?Hbgkv`-Cp(6H99Y-Xensuyr< zUm!FIsd?K@D3~9`%Z=sZk*=8z&%xL-pq^)WoK@WR$h@(zb?|Zzf&tt}8)eYV@%GL4 z^UaKLxANS8G|R`9CtyyRX-I96@+|B#H%wQswRfLupphxYU1-a1+Vrz<2tC8Q1%z?l zhH9_a=y>5YmH9;&?b{}bFbaq!e$9B{ODSJtj(26(>aD`qg65b7XtU}#zs1}vzwqH% z6ShXLK07?PH+rwK41p>R;Z0{Q`Xhp*|j;cw`f13V6Y7x=K zv%8y`CC5fVXr*`x=L~OOe9j4Fgl!?W_-r+NtfXmDO#g&&2O;X?)Qk)WhNoENmq=os z*X8(kaz~(GK-oHS8zzk8G#|#-o1Ui~!HyPec05kKOIy=P+{>xDl$Mf48qE04fgRmG z($mDet4Y!&by7~gOLPw7=jDstdqNyC=Y~gQ=4jdm+TL?G@9l9&8LNL8VLtGhY5wCJ zkI`g0Y%TC?fkuDK;?^C}vk-SrC)pU!Cg&%G$4?EHvGK6wmQ3_bCHSTM0lfr&)~Y){ zwiZ!HkI77WW|kpCIcmJHLyysTPHJ~v3$@>y1=hYRIV61o#^D#kki(<=X;wbih8F&i z<7~MghZBKscNrG7M|-oe4|T4;aC9&jJ8c-sGLtcT3TF%PQ8%H5y2VR0zJnKYf0!LT zT|UBcyQniBlyc#6YzLv%B^L%q_*laC|D)m=oskL+4CJ;ISZol(5$a`s3lQ4^j%-7l z_p13*J(~jucX?WO{O&l)nziIIOInj`5J)p9OLlVUVt zYa}hdEley#(Va=sk}b~(&R@o3F+CSJ+xdZPnQ0!QoF_R&&={IC_`gp3c+A^W$IbFP z#f8g8|0D~pZq>7dt-WMRP5_x%*LeO8?Wpmzar=eFFB&VtkSnu^)X+&~2WHO%QM!oB zNB?qVi~l{OJn)Em~@ySkcCbyzAlfo9GbVVfPi_W zV2b5!!BiMwOd{q$W@T27a(5i5kyOo4ZMx~V%WwM|J>`nHg9I*S^MLIWwN2{a1l3IM zlm50fKjSj@Glo0Yd)F6nY>bcOBea(D<{it#5g9eb3Og=j|5Fvy&Tq}3e&dFntm;hU}OBn|uE1D%7pWhN0p4vMwrWj6VsrUaFco z@zda3J{l7~6>d3p6_me2h( zc3dkuBQ3LRRpgA(qNsjmplwgbng_eovK1yIXjBv){ndsnda6iU9%vuL$-gy{*Oht@ za{d{V6Da2jEh*hjC8t5*HKh2B6B-OPq6MiiMIs4qLr%Pjb>xE1hVl_qhTs+~zj?=_2YHqHQuB~11aCjUn)5MRc;7XIJ(2Qx#K%hZ0s zSPK`aUdiBt!KD4jD7a`9)^BYKwv}1_1l^kXc?RX_f;aL-{7Elkmd?G53_y3ujO+#6 z-Pc&>7%yXMCnk)08E2%MxXcW@baeURR(dF=qptls*ItJ^fU23yrhfx5hiA|KQsbIF z&k~*;2(>cix8=FCq6w#6uRWDqm!6TP2K3CC+w|(~w4h%>F!FF1_x8<^w+zniO>D#* zM%k@N>eeFxg=JwHd%YPpsqe`4lsR8<#vkiKGiRqRL{CEDnKYEY@VSi4C{Dt{HsZQYKsd6~T@$vK|FxWq}7pv;Rk z^CIsd8s;;5GEn2J)%8MBJin3eEKI10m(F~@Ec)#fJM6G5Z!k}L3W3ed@GxGNsL=Q~ z*AjoGkhx0{QyZ}?8GmNY$SK@4NNS+NXlTM5>$@iOIc4U+gk}LOeqjs4T3|_h2S40| zpAReY6tmdsWK^Ry1qfl#Y(HPSSx8%(o5jzxR}Y}=o_?+|J`V^qFZ~xKo^H#t#PrM% zCgWl=l?;#PfZ-U=b2M3o^6>=~R6CYcUrRojG?dCxPce3OkCrITrl4I|l8 z`8fV`>+3Zf3vK)@%KM@LhsI%2t9JgMBdVTKs@DUH_aLG<9js-BA2T}FzpdE1>~J{npjUKMD%QVs0_jM6XQquFx7%tu{zBe z-AI1xmC}O-mCQkt)^{fM1rn6s0~o?%>$c?wN6xOFrB#G_aOTWs8yZ~JV_ zP~gXC%5fR~WjumL;pMyiJo;T~1a%d(E|%BZ{$kADL9=@=!H?&fy9S25+@8qBnAC*o zO(7j;21=W@*vyJl9qX@VH_a@zHB7Tdh7WFkyWoL3-R)w~AmF5aX?O~YzKe#w-BxjT zLX8dC(dN!hmvL%30&FA0me?-G@8lx8Kab+?M z5!%LwcjMK zh6-@wlvY?YyhTwyOr4J;n$<6pWoY|pWoHzgroqz;nVYZ)E_PZQ(1@F``4zj`l4=LyWt>WfgF(n2R)|G)|O(@wB1dm!6)3PlbPJpg_Zs**~>vf{Dw^TW)-^bBPf+o2$qEx(1p)a;z*lz>Jd z(sk2|KFZq|jjhI;)c%?N>XIuNdQ!~i%vhQegDKCM34>Ig?7M!SXGjlu=TMF=RT!EU zZSfbPsTX!XchZDbjfHK_LiQBm_;W0Z=S%?dZtYW6`vTi41RJ;0lqFGOfE*^K+5;Rc zAjIZ`^K{gj+;yN*;0Ee4bWQ3-R`{6Ho3^^SL?iOh33AV}+0M1I2 zrl)wU@=5~D**96*8(V#u6MoP{%{%k~XziMYN^aQvZG>jRK#h={LoDw%ZOq4x4Hw3wb6>}cU+-8+m~@j{!e+cs#bkH+>CMnwY!54K89 z1!G}YI3X_84K~t+j}VLU)JSr95Ch69-fq{q#UixuBMz9x1oG1^lvV2p)z*|6DLbCR z53w7k9^}1HK%kW6aaL?qe&-PZbybWqMA!MYlWeDHOHvrSS|@ zp287)s9Am#8i(2GidqS35$c?gr=y+JT;h);Ha?-o${ywE+5-fm%d!&)Q-L(ie0dTq zHVqVg+Dz9zDXL=oXcUfY1hlpvk`kJ~ZZgOsFoJsf%vI99;2>{#`*6tUKd?`3NYKRd zsQJ+5lM@}#G5`%)vqc&IEw zhNI_mv)AjAa3>!jr|pM~QQwU}`BobKX`G8`ec8iwbWvzA6V<&Ks|GCKKzdT9v1n$O zH!&v&Vkzy=6qB4}LN&a|nrvolaQvWsIX{2+HzC+BxGu{ z+}5|}7v554UUk%rYd=bU?1saXaonoC-SKmI;m#%gGTb%L){Na=ub(HLp`3qgJ|+t& znM7?PsU1HnsQT^4%UE>@^#O5Q$xMIn^0}L=)=oYnk|FbTV0_P z&GIav!4q}mmSOgSGk_S2fRpB+P$XYIMzsSYS^&xJwC+Hxm$1!43p0*3PInFUnOZnz z9wi9*8a=dM91RfyHH^{N9Ll+32F%?JFKj{q%ZAbmEi{?wDhx7~lz7bRHWD`+mpH!_ zJCwm~1*Tq`YQYwOI3hsW{7`XOQ?@h-_m}Z=i>XcdvHmL4G}Z4@VFImKU{`?CFUY%Q7wOp=@?afBJoq|ug5(7#cHGNec0Yz@>*$IW!3W$h2Dfl!N~ z6?KY)w7dPn&ulZI{3C*7oRMOe3UHAjLm-)kwhU>F=6tj{9iu0jxe76?i5rN9B*h*< z@le*2sJP#>y7e$)Q<{dDi`k51{vC~%w<9Aj_8x!^d%C!V-rKR7=~lV-C$U)FOJE0kxY`+f(t8K$ZSFs~-&^CcR)I#0K7zvxon=OWsVJPna`@NH);_9(JrTlN%DT?zLZ@?DzC@u1+2)mKX zt20kk&FHy+&j$<=*7=KrhyCJSw><8;ee4!eMdBCnJ7QKLntg-@=M+a9N=-n1pWlr! zpjk?D%(bE6cGz5PR+sD5Q+>hQUx=Mec(~#PB=PE{l3c0ShQ*=ZSP199=g!opIXPwvA@?ycH`IMd4*;QJxB*=*KCH^cgRSY5TC!4xKW zU(D!Cw8?&P*e#AcoAnF%md_4hgmQCZ81Kz5EAVW77}kywLQ2$TuWD8j;*dkOY6O8BOZd3rmTCn)@cH>mTze%tTo1~G0pE6`8&q> zCOxe3vv~4svQq(@l_TJ=x!kNSrt1a8oTi>n^;MB0f~!A3T!ei})hkWN*aDva9ii^X zTQ#Q6Hlh`+uQnSeug=HeZ1sEQg>wj$GPBTowRqh1`<<8POqQY=%0BFYxjC510A!cc zfZ2+uy9NaxyXla-*DeiHdV~t6_^wG}3QiN)uk?BSxETgV%**xqY<=^{=NJA$(EX8# zxzU_S*PP}ZhvraD&8#jg_8*A7fle-UGo|skr*ONQa8!j^Y3O=)dGy-y;N@Tl;v}^6 zzJkG4$)aGF6)wy&A9dA>U34hAK3m^DT3vr8#5GI60TN4gg_(c{3sMxVd6fAkO}hfv z<-rS!y$9I!u>h8b6;Qp2f(=ZYF9N~lNcnWrOQTFN#OelC2eCUCr~!GPdB$u zNWLsthpilmdAj5)C1%&}-E;rE;Ft?>D>IK&d*`^S+|g&fI&&m43k{Q)%qq~Lk{fA` zKFedLa<;?Ut-8NR!Hww6>TG>`*1YcPxdun@(|&o1_~%0|H}W>Cb3eZwUlOWcg53fO zq|WJ>etD7)y+We22LlzL#sg+$l%$mJb>lzJW3)OpaZIwgc?!icVxtvxTegK%)O$Eo za5b0FsLYrImuKQw*N@3?en-=UnHJ#OVSW2#ee>AK^9I=ntdCIhjUnzmf4LE#As?(E zR!&Yc?mtj@Zm<4nP(Fmg*ow7tj?_dnIbc_f4JPrMa$wGpfGL@NqI&o?B^N|_o_vQx z&g~Q_>MV`h%Z~!s=sSc~8;hS4Cr%n?mz?C^J9=e#@QOFON?62K000mGNkl*}M`%@eW15cJ!})@8`}pyn$+a{FMqyt=Msv3GxY_)?htU`Bj0>156|dddDV zua7DrgfX!qfhy=#dKB=~L#K#u9=TbjF?I{X*Oc877Y4D1=MW}#ZocT86Od?-m)H(t>PfdeUF_fU<{;F|WX*B6y&KAy!>;K+ zr{D>G`*;w7KmXun;fuY8e-7qgt>OLr_VI9gIn@_gyqi(w{9DWY`$62PZ;mDpl(dna z%#K)Xd36NNj4;NY1FhFLPlnZ*$&c~@w6$r(&Lf+eM~+YSPL_KQtygtxoJf+TamX#!no|{;lkCFyR?9Awak{>_`1R`Qb6H-es2(9B zWH~~$u;iz4SL`fY?!UBu@>TH&WHg(1;|&?dI`(rkNQp_aAo46L|iCb}W@rZoc7HsdlRi^K>H)>rf}K1+#f* zZ+Xv3L6hF%1l_gjc6sN^UtfPQ2il13Q&Z0ubau~TaWcwE9PGAL+^Ip+I{=%N&46h_ zkmsM7z8Grg)r-`E1WJ=Ymcy49%lpaf7Vo3+FUFmSY6MG6Xi8G@S+aFV#TgU|hFyL` zq1t&=T6KzEw%c#aE~G>Q+8D?j+WtZgtE*40&wr&gLJm3h*HDr!HLCHzg@cnf9aXEx zwwJc^0DB5^KHa`>tbZ#emgBud`}e-F+<(E?vtx=}cK#(NAVJz4*ih}Lvck9kqsK0m z4BtCW=9HXD=5&OgS%jRqxjl1Myg6|a;ttaya;!^oqKi`(g%RvN!o8!fIOmq2!9~eZ zF+2EN(G(1ZBfZf6nGe)i9a@Nt=~tQsvJTs;TpV zO!_&pzO!jkOWSc_&?uq^+xIwbYa%C8nu;>iV89H0+c?_F=3a1Je{OR4%yRR##W7hC z2}w{2E`o~KEgDosVS-xC`e(wZC^WLyVz@oOz5Xj*YzutqG@`ro{JsYR1 zo6oPG{wy?|#^owyX(3YaMU#Lc!ole`-85Cm+3b0m!BhV2+EdtY(NQkraU7g~RQU)e zUcppf{L|h_XiLS#TwNZ_e1uRO@5mQ(A@?kTaSi$(&ZBU9^>KtLF2Gbp%*PU%Pj`Cc z#oM`VQC~WVpE9Q6HAa5b2ocN|Of}um@;H)?Ie!1jm;uBgOJg&CR*$!W6!u$dufM2j zHic;PT?w6(S-7dTcT`w>GMJSFVnX0ZYjD3;0h665vi zvSO_nt!jnfBRsYM?g(f%OsJJp(*Z`IZO68>&tmZY!rRB?viKMZlh=YQ4qgbA_TTAcSAtennF0{9)8>AgjBIUr^`JhytKMFe90L@dAnLmcm&3ho7P>J>q%81sFbos zWV&%$Gcc+#b`3@cAp?tt1jR;l_Qx=+oKQ&q!ss>hreK#N5OLGBX;e6S_k%?k%~q+D za$IAxNMgY?tT02$$*;pu8*z$_UhyJ>tQjj(l@yWhJv44Ywn zX-41psHtNmVz+nfK$*rzLgce6gx`3;Ry@|2Nn*_)#MTxrmZ!u7pM&*y=oUwd<-JzD zb@~o5v~ns+PP+fy>IBh@mQDo-M*xQnYkQ~t-opUBFon2AQoPhal{8ygU8NpF zv1o)LC!&7v&%@3y@{5PzhpCYDyL_!q_1ln^u0c7?|JejBzah$dM)YC$=}n+ub;;|} z?F3ne2;%4#&|mQ@7;Z@o^-MHT>XXELb}MNdBMF7Y{)^rsEO#7Q{x9H6#nw}(t;$Lj zVRYP!X@&jq3*VO;XSTeB5QkEH-aeLvW>|rK6Q!?Snd2ig7s~iYa=1A3V8qAYS#y2% za~EURkzRJ=tw+CKkoW&#*hd(~h2*ZLY3c!B*k`Z3ku#_3hW%7g`_(`3rEU6VReKP zCvfUhIw|g!hcAC=Crb$K5Lz#VmVU~Pip-2CwLo)WR8||Drr|T8RfTuJlV;}4eRqd9 z$%-V-$1QISx0fFRO*Aw@vlKOJ+itRU^p&>Ynqsv~skc|VV*^rpPKV5-VG~p|Wj41L z?^C%Au1-U4kE9cku2l!eUrlR3S!r!965q8_@~zJqs{GhV!9b+A`;a7NLPo36U%SH% z7!auR{IRXdpD>~51so>*g^*os6RI!?eqg7h3WZ<2dEG5qgvv zT_YV$_0uL}!6IWUP@@)&mlAVr*pQT|%--RvatgY9t7dLYDoX}ZSK(<&P&|ra+7M4V z3;s#(uh=;kcTdmS!D%D32RdyM6BxzdJm47&z#V1(dk-o_DXH(%@A{F`o{T0n73 z8FDmc6u;vodD#N*+Wq4F0FL04>Y z!Vc0BihtvTq0`-mfuh~QSxq;W9|$FfLA#~)>RIMM-PZ`q<5$CV6GfL+lZ~Ql(DY0` zsaUAyus*`nGbSBZS+R;C+IO;*5bL*!xJ&S(ei|z&S`o7K-MRM&Vc=gtD7(6&T4+7`vnp73nflB@JZxYT^RA`S3Dm$p-d1dlUl1qpwjX#-Mp|s zX6*p+)Ur^(^p=!_cEF|#E{n7Z6flj1O@YQL42i}5y|jYdYIYT83*~MV-Wg8%6d~Wt zA|=Ptrd4&ET2$`d9BpZ{p{+32uSsF0Z?n3v*g%CYir7b!mM3B1xXYnJ-hexJHg|rZ zS@CaY@QcWs%W9O(ja`2|tDEpS5{I%sMer?MUrjGiwqi0Z?OHWbqe^V1X>|D2L$M94 z-885QY%SM%k(xKUvdQaKIoGT6Ip~L&iX4BwFxlLcHh&wl^JO0RvvDgTbg^W>%`gv9A!8;FTj$P{BFIhj5JTv zP%Z{yl>o4PvWOHG%8%?999d;2>8n+&0uUB5UUhHJ?f zDTvuy61wnFgf`$P7w(4F^DOo}K;5~jYApDR_7*3wxrS^2%!-BJ&kvb9l*qyqCVrDQ zE{b^tNZG!wi zd*;-t!yeL|HN4g*Zb)BpV4NY?h~!tRS!c#2UuK~ox6POLBBh7q+{B`bP(Mo&$CX_P3Kmqh)`ar`Jq;fD()QJXFlCvEbg>G{)x-t;RCKfKHmh}dlgAit zMsP?*SigVN_ctTr8(T9M9Fg$l7M9pr7$w%6xT0G-jP#3!VXxn;be4=0^_8t#Ec>os zRdqEHm=nhFoC)C0@TXqFN>)zy$o9S)4*Fp=zn4?od4g}EMBe3HgFnG8p$TVh@GZR= zU}``Qadg--Al-7&_3IIQ=HUEX%Ix}u4sS=w zZ{6%qnZllKZz@D7Lmtvdmz(LLqzq+q^T;`(-#gC08)lU+2kSYp4tuG%o< z%Ri8slSo2tS#{$NPr-WamRND)C&Qf{9*L{KB<3;`k1YX;t_R(5D4Y4kfD6RfFz*Bt z(Jf4=`;gdi7kq*PLm8xxSm+~|{e>tV;G!wXEbh!gdmmPurG1O#y7P1oDG<`kFXOO zaAny(Laj?7vX77w9orURfbMJ2*K$czEyL zE3ba-r59g(aQ{B(CRZI{RCL{VdOF-4f|9>5FR(PN=DL}xLRr5fYzH*sX~spB5xFzW zScWRw-<0nr(`{L1~C+APjt}kvj>!A+|PAo^oYpAX~49-IDt7C== zrcqp%4mY^r5HHpswlRYJmp0(}6YjMrIXOzY#$v{d|segVZFaR^nAtQ2#N;cF#aUH9O4 z|CN_sdh^Y@ZzKInmrLiE#uJL$i*FJ&6oRG_{QG9j-`uswTLo?nTWK{f(5it|w9r`zaRno26 zaol~D3#V;npEW)(^E@>+GSyX1`tauFp6opk9w3z#t~ zuQNoC391h2QZ9$H2SLtvW%E*bD$7&wrbRkP{-AKcG@f!jV!;feFLGLCa5DHVHFUh^ z-rG5(deG}m)sK-##B0!tzIQ)eMWK zUGl+;-?Yb~9XCn-OW#7%=#c+sro56VmVfbERxVWe+DSxDzHs??|An&Nr;mT;689dw zoyix1r%!+BexKg|x?xS9fBy7+_xI%9S0kDMQMf$+!1d6{$?MtDC&AU#C+^45F>iy# zxHxrv`Gxy?aQI@*3v_es58>_~+}C~-NGt#4O%AVXVfrwW`0cuZ09t_OFpu)LN$aLHMia}1F|`AGf!mQMw1@Fq1i4l z%6k`VN3i&hTbK~CJAv8Na7>SD9%veEBUs)E+f>`O{0WmTdtARTRcmTp^pj!JbLRf{ z(|R-6mbW*X8<*@C`+1E{E?v2wi{*h3RUQtj)rAAP+&fWXOZl7YCobMUc#z{bTwi_R ziX0rhAo7lHFB`mEr65<691Bk`&p&i6GICf9oFKR9KFeteDiW18@0xE0bM?;xAN7 zJt%r_u!_qNJ9Bm<_-EWJZs~CtD!Z@?=T34tW~ce8Ep@bkaWAq7+UKuKhiDfeVqa!U z8rvHcQmDMwIt+?Eg{sd|%*GH^F{JWPr=gYZY-2QydFih4GrFd%voL$te0M~Cb%ZgE z@`YmF7bv?7{UMPssJM!-3e4w-13`V-S(6>F4u`k=%m()t-5amI{5#+ID+h*kiOuGj9LP&dvE*Wv?^pZ zpF(~gmA7*$e)0LwLix9xgyB7{9?oWy=d%cN`Rgy75gGG4_B1;W<@(Vh2Op15-*mp_ z$h@xJCy##Rl1Im1ck+%W#vZF-{q*tsK^T42@21oZ8g9^@J^j$3wjVCvQscu<6VJ{* zb@2BNUe(>4p6)LH;_P$x>SFIDg(HLi;^Gnb&9x5|ju@|3mrsJD^B!O-9`esaj~p9Y z(XRvC++2mMag0_gH=gB)pOLWS~vAe25@& zg9ZAzm8+@zaDMiwGgyoqmi)7`51lv8%WcqmGrSz$I63d~4-emPY7UcL7@j=-wUg3L zFF1V^w%2j~`thTmyB}VUh<+*b&fDPioz+KCPxZ)QmA3qug0*S&trv6YMS!j%6Jm(~KPn zXuW_YDi_=MjP7a?#$sv_dQ*gbdtsLV@Sakxi4VJ)W61FYiKc^Byn~K+6Lv?-#W&u3 z^ZVcb>wd@UKxgOIM<-vuzFPnK!%KhGob*;H>P{cL6_|j1irhkv%{}Kk9N1sqrF?|@ z55Ex@y^rUM0}d(Q_+8z2?_GYN(gNl1a}XNc^8UkbhWsVpp2+h&SDTyj1#&J>J@)=V z2PY5S4EY_%tvZ~)I63{ACygr{XJ$Up9i6`J#%UKP64HUrtb7f4nH$k?T6b{ta+WW6 z;{L&lp>r0d*ZtGI@JYZh^+8ZJ^4~;S-V2s_APzHhpisX&3gbmTEhw7A$%lh*;G{e| zDTj-|F0UhK_tc6bWFj=YUy1kZtJKH=;ktYV$o;NN2xD5t)(>EzS?T zgkt`h(WHK{seC)YsY18Tfl2~o432yq9u(Vh+(PGms4w0U%5CiYccKS&KFL4|P~0r_ z9vFGfozQu?_aJb`!jjg@!-Iz+Ke;@weqw*%)?S98L)~PhEPi zyF^v&MXy1hP78!8LcX&!dlZ%mG4?@k4qk6shCtD?8@HzG4@9{IrS;kQ+SR_;`|81d z=h7!9$A9(vzvEu}(;xrz7ax3ay=J#C1(TcY0g9IwhAy^JQJ9g59uV$`FDdlp1`_`w z?>i*zw78BOu#8iwY_26~Or{S}vJ@>-o1blQ;3o;#_hV1TCt#65?4(FNSgl$DuLmp!e_4c{k`w~we<#WZ`Y5X-dtSVUS4lD&NCSHBIM14 zZ106JxI-5Y)t)~zdqFai@>HJpYQz~BpY_*08QGx(DQ~ai;r#GS_Xrw!Z}GIf%)FTP zf%yx6B@+yD{n! zI^v^hz8Z(&PGtbCEzKnFTj(I6>9(* zgDU8eA?*d%x59se>>ZR=^K-K0BhABqtfvNqwCE5X#J!J@SSsnkA|JHgjNit}hepA2 z(H|W2r^kEuPWKNE+!7i5_$NR8=?9&`q3W{X0SUzV$WjWOEY5)Kb07*na zRDxToPJs7JuAE8?+qj7u+yJH^Nb_fZFe5raDV<_vFX)}G? z1b;qpT~`%Hg$;Kx`-E12Gq)P;7D~fzswhMWH~q9A)2w$ZFgWqj*n^Ot89gQZs_zO| z(|A_cHt>)1v~8b&P~w(@X`Bllhl35sqx7V$_++>>jBm2_$4 z3jp3Y=wNTJyMMZW|K8r=;leG4|3`oFr@#E@$uMwv(l62JV!M3NZs6U7#NR0)xddAq zWsJWeyqnvFY+`ED*eE0|5J{EUg{!MiZ!Uf*D5Y#;P?oKepZmvO4Mx`l-%`$Anx-6- z;gcSE+4>NKEvLf2WP9ay7^b48s*vei#``Zo_K4$7uWo6A1#1!NJ9h2Z94po}Uq+=9 z3oovshBGA{TZ$pgzItD<3lDm}Sy0cKbOCf1%*6mF?22Gi9~^D|nLqryU-`=0=NIcw zKfCvB|xJCjCvQNYEj zF)?1Iy%Ex~H6ND=>2Q!=_P3DMnAMKTl9&)4!vXY*=K-wZVA061BEbp_CXi?n5Yw2y zUXBLCj?!>RP0%=COPU_S;>Lg~hNOH(tAqe!-lcvti-H->h8jXE)ugf)Gf_-py&XT# zk9&hscc7`L>i3!}#GJAp!HQ)(?+%bRS3R9WYLpS^!AlyXK2u3YTtm#rz$Y+6SpVhu zI^7B~ftbQP-cwydhv4=`86!TwBi`r}`W-Y4!ULU_lo*sxev9*K{EK$6MZbWKM6`WLHd8iWC_m(!tbBzzHv;RCp&Tq4hsXTwc*Z#lVvcKB7xT@y zpvdhBlgQO}r&X43+({x;()g`GSve42E98R_xn9CZvsKP0hJzKekZ59MtV)<%vPtg~ z<)SQ!xQINO4zy`a7?~DaaxAxv^e%hW{39F7hNxWFS^<)nCkcwA3s^lx!Ha@JlTU*6 zSLvguq##2cTWM0)Zr+a+&I+$q!`0RL>Di4-II_I)l~>++AZ`wrA9K$H3coXGGN@-HHky!^NY*e39Au&YVcTaT8_^&Yg-y- z$u@lou5Lmraibi=2~4*8sHW;Na9TB*rz5qu@K4pZ`e>3?BQ;Gmk6^m1XBL_f(3-gO z5E`p@PCm`3h2UAkB($06j2GAR^m1*8bzoy!wQJ%z-HHmkt$dd^^Vn8yc%pX zDrnWD&ounW9HL!lQq*pj;3@Ym&#S%8H*ME~HCDD?#yoZsIDe$}2|A2pxWl`Rbl3U2 zY#!b}c;Ugp{z3nXU;N^SKmTAQw^k48#?$p;V&4y6{J!C)ka!TKm^`JJCIu4wg>q+n z1Pi7B<0bR=VOU>$@gHe}HZmnAZXKm*0aD3)A$|Pf?}ih@G(J>ln~2KZZFvflEmg+= zX%SYnDZdpAzpm?ge=NX46T8j0345$~h3C?4} zl!L;6;d_eyFj7En$nr_|w3(Sb@t%@!KNEDuVN86&OUk$Mf#;XvaS8eUo+sbTIM1eV zV-8v$m2e%YHv*EcNI02{cSlJr`MvZGNyh~~r*Pq?nV5o(9plFWE z2MFQ}2PnfZ(O>&cVlcLxrI`H}JB5iD`@+QHax zWv^|Ci??W({P}T>=e*YGn40}B=a$w#N+qNB4y1nrCqU+_Mx(VLI)o^z(F_Ct!+Ql! zR@C79CY9utJA`bcfRSTGzI<9%JgEaFtOyf9tnw@HEHNb0Izx?EukmXdqCr+~n!-`u zM@mjGd_vlG?vf-EBEcc^p2qGV691y872{t_^cO$0AePB%za zt1kXRkJV~(eX}_`cRn6?<)s(C_2wI&efG)CDqtX%fUkqsIGDRq&$8tcIV8CfD8DIB z{;>VRO%LVy2`ZY2F;>@~Y*uITW*i{NDTa9*Eh_=rInwW)TF#mkTgJ?vj_oPTKUQXZ zHb}L*2NNBzKYI8*^U+MH+z`nUw1FTN6ffO`O4^R!ihBWrB@wq08tYT`3(9ITTZC1y z@KHKf_NC*^-}t>hIJ;PX_Sx0>KR*<*iy(&>Y5weuf-3n7V# zkpJUQ{@a-N31j}f2j32EPVu+-dLRGD=RbVcl=n}cKKfxh|M3r9UH4vim+xPUT^Hcs z>4SIdJ<_h;bJs4!lY8F?-c|MX4RCS(a|l-eu!~%ng5cupSFXhI$=70+20(Cm@xJ?U zc>GoANfo%)S0B05!QpE;!_dvuXD)x=-&5Gx{M(x+?oT+2m*@ljSeY9zZjyJZIU-}$ z>uXm{*k)_X!{9$_i|JNRin3)LD7>)QBAB9YFK2crLzRQIM|14mgbx2nzSXv85ewNd zuimNJb1ZPOM4Wh*;$eG?E#XlScN+?0I8%lHZh*f0FhQF;gNf`?(RRn);ecvzY{P20 z%*fM(bMw{oRtrPQnv3Jkqq_E3@79u%O%)Amj~998ji2)_iU+*j=dT^kAk$`b<=$EB z9j4W`P~6|Q3KZlRDpnBWp8x3WUbzT3++2O`^7jv4%Gle>)sOx63!PjAL+1gmF5Y); ziX)c85x$e#=f84gj!(ZPVo)i3arSc;pE@}YiO&9No2O3S(aHVdzQ7^qDGK^45!D%H zqIbMLvgOI>sSM83N37Sr4N)Y7t9evEzJrErl;xwX_w30}yk36rtqRYdgz|cyBKNY_ z<3Dv+-E;XJF8c21X!*)ZhsVc@|KLCP2cKT_!4BrTspVJ+uq7OFWSgKDiW`!r5WI%E ziO@J2LxP=!r0p1Fz(L_GbY<2TkN((cvzSRxo=4#Zr*z^sHfN*JH8GYc{lzjb69(Z1 z84v{oxoEX^o25G1R3VQ2si@n_5B5*q)B=UDMq}_bmiY-0gzm z!;5qqcMKL%tXsgCG3AaLMD- zw<+Aa6Y6#OoAal?a>>KfuRDt?E;V`=_vHr;<%8olAOPaMJFdM~mmj+#dq=N_eAN8m z>hd#3!sWp$=w10ZJq-O3Bw6AAZZ`hAoY?INzv);D8vrQW1u39)wjE+webE@2hGS;>sNBK^7neC| zT4&lO*2NxH(&L3G9cI#c&zzj#Mx}t3jtEh;+&ucUnA(+NnM^~Zl}Z1Yc2g_1G!%z) zOal@o1x(Rv&NF+e(v+F(c?>dEY+CiOTn@qMF1nS1=V{3Dg)&wCSos>ht+$0|9@p(E z+FsEo(G3kfi9JBVw~bzaoX;~G&GfI)@TJOu%=g@|K+C7eU{(GU?iIe}G(h6*q2hTR z3o=Fuq$p=v%f0%%z=wNltc4j3nQ!F=8ZGNZDDch2px(7Gd){nreO9+GLtBk4H(7NH zj{R^*YO^sgC&wK4AjcvOL1TKk=O(ugb9o3i*N@%PASZ*|3+^)74A)nm3>$KCHJ8Iq z-hS+4{=Udz2#ZPne!Qkk34+VQ(k|C3pa#pUT^-HD-`ryPth7ldhi?I?~>Kgv59)~ zo4jmj^yAM||Mt25b>-ps-dn6UnI)ng_f8PN+ksY3Z=KihH=jP}7iYsu8|s}R z4QsZ{uVDe$vBSW35b{Xu18IX?hE5qLEDO<)1g&53+c>p1$f=aTfhs3>6f*6 zRWUL7v3{2&EtSYnGw@kp~-yK@mA={}=bbR93m@rL8uV^O&R2_YZvlVf zci#Kr(T7i-xFzGO^@btvdUSts|7}l!u2&!dr*3ib;G5wNgGIb6+vD6VeLng8XiR%{ z>ipa#4ER1>i0DQA!p(I;{zCTV^P`X-_`v47(EEJvH}BD|MEmmZ1P)#B8Z^q<$KvGv zJHYU%OC|!qv2T|Lhz5R#{jsy@eJkTVf*#KfmwpcFRu95elnTRN@O|LL5ajuOxa=Rj z7KTqBZ!Zg7*X=h(sU+sb>kwYMGTO?oG*_8bBLJSVS6WOliWTHj-+I z`8bjf)A=LyW+B7bwySQhp+ildXxQbHK^sFEH&Dyj$UK`8O4^UyTRl1GM!Z3;orP4YncB1uTCrSm>Q zidT>6Si>M^NG^XB9K2leYZnCkB?5@7;=jrCK(CT9*2IZk-zY3I4p-_y zyC>=wUie))-yxX$1vNkDqhSc&dF!NA6v_uUM&B|0lO9z4=<}Vym-HqkA0@H=4SH5~ zQn6KzqL*DoIxYhIq{sP~sV{zWyK(B?nUD8Qmfw5vjeq~2{NQ%o3Dfu%0iPZU39$=> zNMdd96hbelK_+b^O}L9k$zdJyxf(<6$@j!iarIKvv0p`WX$R@J;Zw^l1TI|1=z+p^cHsCoYRcZE}WDCa_ zf$M#K@`XCF-h8QG9LQHmkaWFReS~2tnZ1eO8qeS(^nS#{W%r-@n}2f6CGY4_8hjPx)gUvK>HP(}XGg=rI-JKjP*b!oSaKnu)M-#f_ef}!<| zV;^AwC+5pBAZxZq&j&zuZybA8x{9YzZ}l?m1?BR}F|0+<%rz|Qn|l)@D<=qc36c!Y z*!%Oan$c7_(chNFi+bbYeQDlHL}q!nvB=x$-G_Xusk!fv?>1{J3Rj~QM`m8eW0a-4 zgl7@Qr;V|1`qYNzl&>l@_MlZ}!dq#vEMdB!cN>)Id=9xFN;O7dOR6cRHVZ@#+&y-{dt88y4Kafg^j%lbFuW1U zi!fE9`zLKU=KeyX23gN$7v6#5IbgguQ9Zkh1wOpLeEqdkC$s!LWtF2itOcr zppFjy$IhlOvk{LEdz<*YNB~Z9vRG|FOdqn7BQiahM@M`8-}zg=e{)N}{_y>A($LD!3KV9X(c`AsQKnARk`<3r zC}`7j0_R3uWtj~AsQrTt+#0PQfT_A{4mK2Li%*&$+b}ER2_~?LZBI?fP2}?Q@l2R) zd}!WFvjW0apa|PdrJdv1M%b>g#W{s$p=S#j$T6d#`9enJ987NDrl-@GQ=^HD)i@4g zZK({z;+-187;aP*p;o^@iw>3oG}+#o=I!zkT4nOqc)}F4tQ8jj%;2u*KaJsZnKGUA zPG;)S2$zpQ%gwv3e~#wS#BOB;frTi?U|d;4Gg%E|s7{(t_RfA{iM%s#o2Q1%g0 zc3~kKe=-z!b0P5d(`5WfdeGevYX3Ql@aprM%U^+fM39${2W#`0orUp5&;ph>v#Xi* zkH5A&d>Pt-rt;F%#h*St3_v*pTXJ0O&88NEPqdQqC7 z^uZuY5VLew=KEbKpq^uUGOWHDuQ zQ(Y4^nle>nk|x3PXA!Qf0Wzwafo^Yxi|h6A@!mJz{M!2;e6SuMnK&4&mSnDI9zxog zfDjEAz(iT0wWf#l?d6BJS09HTm>vMc3lM=VoG^maXtDvv;?3$QZGzFIUyE%OYX!8) zbov-g40Ws;1B2yYq|7mi~waT%&ZyN?Ho zV@cV-p@yRX^z`oc7WmKn!QZ;R8GioDv&*ZExrPv%)Q|1SJT9p;PLkPD729R1Zfn+M z;}HlTJh{Ctlg@!|X+`seQGcf(l(EtTdrrcs=u_T_SVC@l*P8XlQ9!LxXuWQ_q}~A0 z%E?GIMrh1X*2-GLQA$=&#wa?GU-V!$a7X9>W}FxgD2$5@u*-9h&FotY{pS>H%l`a= zp+-)t))MDbENz);RpA+JATf>Nmi&mx$TIPm6O-CGZ9E$#^IDpo#p0zgI#lh7pc`$> z=}%;!ZEH~uGcT7>uYy)nrg~bmvH3L17=bWa?^KzBCU)6%9Q&d$ELI4xq%Bi}# z4v!A|x4wSwV88o+{eypSyC#1GM9kG8Ak7}ZL5~%aIuuEF7KVL?{3$%LpE-eWPtIz6 z{l)6~6K5ByaT>rT{~v8}7AB9Q6%(NEMy=5;4_{jzzLbvINKB=$C@Jjo)l-<+tB}Xh zbWLfst{~ZM$$+yj`^08_?(E`WeI1Ae{BaTMA`E}*ZbE%M!PEs}^(M-_gJSJrI4!}} z9;Vv|gO5;LhMEY>;>^kQAN<~5+iVtp_RI5&i!dt~lxNbNVR0@+%3qMYWyDMpjvfV^ zbwU!@kRaX*6(&sV5hUI)%dMjrOL;W;%m!RO`2utJy_sQ$JDiBJdlM~4wTT4WM?Rj8cV0>4$F9-R%9&(YVJow6jErM z(K8xVk+}dYLz)bf(;3kGXYSjR17`9l)ukSh1UyZyFt4o`9gqeNWkx*ZXsACXrp1Fu z!ljs6L1?%ab4)PtHt)`1YR4ErcW$5&2#S@$p{%ap8rL$?n+2YEH&M}3SsSPSnY-@t zeyJ$%TM(z7EpXn_Ov=&)tt=&#!a6p2yqocbRewQaYASs#TX7Tj3RiS3d1!g5i^Jp@9P4sM(`6Lw$ zNnZz!;&CCLu*0~w-spF~DYbLv$!as4pRG?%_rChtOYeX9@xUSPR1gqu@^U?lyOgcHz9cE&2N3;>+*jX%DGLs#V9{rAGDXpGyI&mV{U2k({eAO9f0|4x8l1^lzee-iS)m-5jIkn^Vj&i(I@bxFH=&wk_*$M?Q%yT*mfv!A*mN2lKq2-;Cs zepjr^f0IP$Fx%4g#jjndgX6E|EJin%AGy^2(N}Wzq1&s^9N4{sml9V7x~*;>x#ZH{ z*OkFphl@QId#7r^37pO9%Kg!M3UwU%TN3!5!UHe^E6BQy-+qkoa_o8>=Akhvu_|Ic zhz+0a2xP0CM=LAejn!=T&nb;~9MM=?Xne?wz1NZ5w_&FT{D{i*VFSg+4qdQsg| zxVllfM{xq|{tUO*pSkp2+#;rMZmvFZX@9pv4#&x1hv(tRS9PPEV0(S`3zt4TeKT(; zU7r1!i;qsfnKgSDF3x`J%AcHmJ42u7;>izOeEQ(MFe%mciryjBM~OVDr@oWvmn`4* zi`RFq-g^PQjEhk3xvSUfuXhy=2^`V0nlEG<&Yu3ry{7uwz<=_iQ2rg6do#}rtoO-Z zsl#rr4)2{Vzy8MYpZ(+?{`eO^@dhkA_Pnj>er>|3l8v|wiNA5;_ZJ2eSUij2_;&n^ zvbPN5_mtz_L2(eKH2VcNq2KD`CZNtj{+~*21uzGzpvlkB4rW}0uo$rjYhgN}0LIRy zpi!D&6b#xpbU#Mlo-C30A04YS_E%B3GY`b9(Y=Eh(V_*K>JJ#7N`L5o3 z-*)*?g$$a1eD6J9z61HX$*?{@V>sUl_bJNMDYWayuK7m+A5#8sTzh%;GuOgHFBIbK zHV(nf<@qn&s|Uw#F-cj}`uxjZJB;^_zs9#P1oS(Xf8~uoTpoTc%tQFpEx4g`ef2T< z{8t0~BwTK;{pHomy%)LLMU)SGiK`Qu53TX)%CB$F+~DXJr(u?de2cwDXnjTgrl2DL zF)6}am}GpS^57RbdxSfY10pT&bEsrd={$S3AhiElMnQ3}DJ3(?(vCQ==qK&!dM-B?;&x9a9b&+c+Mk z-z^_&_+%s)#qP@R$#%jw+LhX?ank3OO39vT31q7JA|{M^K=xinOdJJhPpwjt+E^}M zhF6d`u_e82c`tIlVdL0fQu&|p4CH7_J)suVu@o_Z%oL2V@J2**LR*2sL>WqkOL6*G z$>!{UA*}FZLm|kSVZeSr9Muh{PBC2Ey1w>z)OoqYf+}D>fR|$f^!=%hH*qE1-aK)Y zES3*6huh;X`FHut{g*@@vV0kwTy^V@v^;nfMBav&hHzi(X7A_?lx>j|^1a-4$guFVBDB(kkbb56BsXKXdR8Pril5ndkJ&nb$9Le^Wn<7gKMH#1J=Qy{j|T5@ zV}0a|wmu1xq}Np61(8E*m;aNXAK&5odRbN&z4;E`cgWpCn<430!QT%dua}<~y&Og2 z5cT*se0i2U{)F-B{OQkv-VgUCf>&Kn&sM+w;Ox<(51gs+#l^M1_K`$B!O;5>-o~JC z{9VLv_&;7gu}RXEJ1>|hD!SzQ!oV{dz7LVrSD;d-ih|PvWK$ih3xc32sX zY3`%2bQ0H#!(EiVpI&|Owbx#K{qrxbpFG*DH%ZVtMmfIsO^~DPDE~aP#qqswg?xX{ zUsP=F=TXS#`<9YocKN5_UO><*W*DQ+!FeY%tv74s{(IJb*aT|@(kEL&z0TLj^~%jV zfyABU^Sw=6)MM2-{6qQ9eDvA>*B`sNlqb=`dK}%KLyteOGd1Gx5I#KlhHqlm>$N_i zT7<)+uLH#WnkAu|Z%S#N%mBvAI_hB)p-j<41}zq~Fbc5859l@X^c@!bqvn~ja?drSKk9Q(uI zfFsf-njlbAT*rCq>v}cEDt&8UkP2Z?Q>=_BuJ`2e4*3~;Fz^|FO_zAKTP^vy88$&B zX*1>Xm`t|ELME#>b@h3T3Yz+i{B0Cc1sNN`Y)@oXFPF%SPQyE41dF?E`5t8u9%iaa6H_O5f&&MxH3T30X;x<1GyzWRh=h8DQ|<%6IS z`i!c`a*vGM<64}Yd^xDQ-lFI*+&}z^_dKzkp+hCyQ}`9RGKTL3^y<}L2YYb*^>F4W z>K$pt;mO+`zW0DKF1%RX2feV7<+Vsr)>F<1?iO69(QB-C^d3R>>1e*!kA|$OzXsHM z3VEs;%70t*9j`cZXM4RSdQ&H{(&I_bN(V6hPA{i>5Q(!{53(NT`*#H)3i|z`1T7A_75UQRXjMi?@K|@$k5BfORD*jk;uw+Pf z7=mCF@s+8fk2?Nd2BaL)KgzYq5G)24nz;^?Wh7e-u*|N@%LqTEdkV3tBMs78DRB?y zuh`NMzd!%wb0{p@Nz2w#)xO62oM@tcfBbc9oSaZj5nIiAj7UpAln zG49ZcatKm+h1>D`Cfp0PP)`y>%@UkVT^oM9cd%72+UsL{KI0#)^HhOZzPEv$X^=1H zkr=fUol05(rJ1yv0=&J4?qXyLn7bIEra3{wRv9Uy>9mAeq>=brtA)4qnc~jeQr79$M230! zV%OiT_JK@}d_$HRZLjH57&N1?DPwwE`z!cwh*~|%U!h1j1`cJMlSaXe{PJ!;R2;&E z%$@>yzBf29ilwN=?3}`y!bHh@%q16@;t_s)m9Vs3Z3BiDbs-zAG-7Cit`T6|BTgA! z^0S7Msq-A?!QM+MFfr_b!}5N<5D#hp;t-#b@*Ak<1(MtbD;t#{4i4X^8vtqhSWoq- zrQGVUo~rlP8hk`Qb|oDIey9DY<04p53O`s^QUi$|jF?9_Fc{Oq;15H-j}vnIhioe1 z@fSGE^9*LWz!zWGd;PVe|M$Q7f4#oF3|kd9{$2m=TTaVehWrSE_c%CPhcMLm3wVR< z0VVDQ(x)VSEw7_ramKj546>}_F)Z$*k+$TQ(Lgy6sP-d*b=S8zT*8=9m<9IejzQnC-&&v18pesJ3R`sYM8EAo_RhR>JVb4qvGM z{onh~4DjMdpIoh0L!-%=0fDWi0cP`hsM8bg8qs4YQDl+i4=!#Ki<6xFaIt_3jc1+*&2*?(fcfGfX6EQ&Rs# zv!jI!wGpP;BRGa9mH1?4nFLhjmbIWLG}=njBwhB^5>!!;Vq`IFq!XETYs*YHdFjfa zv^1H9klNnLPUdL@P{G*(pP(|M%l;Z;;=**>sa|X1jI?pA?+l()P}P(i$EQ`xa@oE1 z%3%ke|9k)8|1%nVe%qhFtVB#exAGlYvoSjh*D_#q$D#QYvV1N%?JVR=4gIfpCDf1% zS$-A?(l8L4WP$2i$&u*IUdu9_>pR|4$ioWjf4Qw=WMp!_r+3wH6VhzS`J7HX%b3c9 zEdm&Sgrqm@e9;b3kB>;)#k4p3*c~Y8P7aY^$2q+|l`;Wx|N5(MJ$&KY?|*o8>(7?> zI(0XON04vNiZ^@tuK*9hYZxwS_F?%P0f>1B-5=FrMmtc#GTzUwXyeKLJ5kRd^%IL=VfV+{H3 zs8AglMk0foIJ+Voo0fBwdJh)HQO-WyIO0t+3*2mb!{z}^laf~+#h4-lx&IGJhDRe^ zFt0`19x$Y=FTl`N@JxLqy;Gk{O*sM|wG|3q>&^Nu{MQ)|oWy|2308AL$su7F7~1M5 ze-lfXK7xAZU+O!5=NZQk)3Un*_k z7Tm_l@0u@s8(RTcE4G#w&NuXov5aDjy%2hhdk1YkMRtTZn@IM))vjqY8ODtqCF2ES zOttLNF9;(*KE^T00cvZU$g9km#b`1^kBk^pA4oOy(zuI4&8!tL&IW%Y$y?$=+d>xq zR)``ylU<;ajkoo2kB}KtAs%!d2690t7#U^cRX~Gp>1(!~U1=E^2y*giVKRM3!co+a za+z|W*k2Zo`1K;YkS#bX`;Y!X4>eMy#f5MoO6f4X~x7t;ClE-TB< ziy-X7PIR8etN3R?u{@|bC3X1FHToK`6=Q4Ar2@`<_K<~$iU|WVHZ`e}Y#~hje_$$u ztb$}9g2<}0!3A}<8MjWpL?RUP*5pDdCkIE=Yl!L4adH#-7}+lvJcZ^y8t*mSUmpFf z-~ShX^}*G}`~y$o zI6hguNyw%&jzF%nM{1vjkS&p04`>_15ghW~iPf5}o8d<+-)=swaTd&{U~cD6JG=_= z*Iw9)R{Axmky^&umE&fcH1A!knke#t>0{8zmMM!E*zPG8Y8kAwH)&_E`4j1)DHG|= z*i<#s!JAN ztgnk&bePDg>m}1ffjNn?#jt`r@-$z*Z?`c^(@6eUBeJIx7@9KExNU_1W$h8d79ZqT zz7EKoyUM3ywdZZG`ghFpI#Rv_Tf-L(2**c@uf2Zw@BDlJx0}^va0iB?$ActDP9+by zv(O$-CUF*0^c_MlYd6Z=)>+6X)n7MJ-b-hYhbbxzd^x@0SSB?!#TnV*$M+P1n0LMe z%0I-I#*{9^d0aj&JS!Fm7~`)?3zuxJAdQi`uJ(MfQH3zY7r^s#68hvO<*pOGMLSJeZD^z8@j^W_LsvK zFBDn$iGnO+#dBuoqaf;neJYSVOreeh$9T(#WR?kGauM$ZB&t1~iYUbU#wgCtJ7(2@ z4@NH>!p(_p#`wteCYGK&nT{LEhdA?kV!Cf^P`ZgV!Rb!=l+3MfAd^1vmxu?* zdO(@}mO(HLVl>`X7FeAvWB+xKyF#sh4$wZ5&p%QF0+_o1V=X)%jZXp6sD~|26;7Bs z#~rb;>Nv!Nqmy6CG^B`w^o~OYtV<(S6*&PSf0k*CX`X-9`4%QcXo_lHd&CX3v6;Kt zLX#M?cSENAQn`r}oh`K~F2&@15n{1}Pz~vq%*$x05`NltCgxLrT3C^o9TW zaDW^=Z63@ZE-z4L8UR~J$uNjBHezE)I7D$qRgjUFlqJ~52>HQ8hk1 zDFf}H2h1FQ)~gfIPC2nHlog48O8YGv>7ke&H5k@9Ge!m8v%k?=R@I_RQMh#M2RodJS}=+9u?KYHQx!JEJMbvRrC zaB==)@b~R~+nB<>zRR2Gi!*=k(c;38Q~&@F07*na zRPnvjbEQ0RJ;R6`kE9H@{ zp8nL8JUsoDWC>Pub^Z(Y=iuaRZm;;n0j@88zh@VpfJvwsk_rh->nh>7G187Z3*vlHr+TzeHu0l(G#G&#Yr(yCC72uE3Y6 ziVmY*a^fKD^xSlk0iB^t99gOmh%!FlcM~CFuS32cCE}LS&LQM%jBrn(hqnRV*0c9-s!t1e?nmNn%vznh+eyw>pOox;t#wYbns>PvrRQzKK%hn zzSwPy==swh1$@3EjKYF=u=?71OjKX{{7&#>_-FpU;iJ=cL6n*>`{nsh!QbQf?ahFX zp03|~z(IRjEG4j36)I#iKejG$(| z8CK;8qpWF3mSAHrnzsUj*`rhdEosC?VFz0Rj%SKkjPq|cizEA4nH$Gz-0j3(pKy)@ zh$p+$$+1`9J7vV$6E6|)$>Ol5Fy)66`2I8r&&zeWnH`7Fzx&?b{QQgc?QNK#3~PUB zC%HlLO{9fRVEm0i{?cZj|1FoVTL40N#~g#p|F%EPuF}Kb^LPFemw$NTjLQX2y8@HB ze1D(Q;b|zZz2)*fQxY8Bd)MXb9LD9ZFP?g%@X#~m0%d!6fY0SmzY|XBi-hy{AfEkD z^8I~IC^o0RAMuA_kbl<+&~&cZx7X!&hbQmKZ2|!$S8r%<=i3X5=y-GaSLZ)>>HXuk zgR)UMMZ@*wFJ1Y)qc^jAs(k*3uB*a5g<&EXZV+;F%G>LY-4IypedWuHoR~Wd=G$<2^b+IE!(UwDO~9jbulaH&1MyP^bo}D z#Oyc>yaUBwEfj_z=#@}2v za(>pq)Jt78Sd$V)!B#t8suW2;W&+E*npG$XkLG5giS^(MiMSKfClwO$5EU93V%cNQ zFre9Cqpil&*E}x3?sF2+Ee-5Mr=hOIYC4LkB#W>i#vnqg#2ltd1#+h3)ZV}}*=Fm; z?q*1Qh~y%pKbw*elUgrgf&j z0HRfmeBM;rDSM_t$C2epF4;l+-m&FpC`T4G##9jxauunGGgea5mbO8oBIbr#IiO?B zHDKce2VZf*{RkpYHeREkTWV9bWKud~(MiQ_ainIA%xKoDr%oz%ixU7bUnt5wqQ%|= z$tNM}>gF?7e!2G&yYuSYk$A&5ac%U z!13a2Qq>Pn|JG2dt-* zugDvDtf$@!dPf9GFv3``z2}6z&?j6wd->EmO%9CS6uox#6D*18&Q&3h(?|(DseNa8SPWeN>0{ZNW+jrmn{eS;I{5L}Vf$zFO zoJ79AnC1JhDBP*d<`)tJNM2zJ-!hT2&x434LQ2;y^r4rdbd3kX`fxTGK<7JZc7e|3 zz=(Jnu?}%0F2h2`P-F@##xTO7*88b$yY0hBUd3MWl_eoMgDEdb{)TFmJ{0TdfGRJ2 zv>SBBj%=m`=b8A`LQ#Bp??r+KPtUK}RQDdFlW<=Vg##$jV;Alh3?giyTR;O4mw)2# zPwF6kVVc=s;eCyv!^DZi4j<_KoFnDSDFTWt{ z)H~sgAj$6*<~A9RC+7#pKwL}3(609alP&n(K@xf`4o=?*B^G8@;Nk3_d?O_LFcXTJ z2lkKN3UK-ij?e#Uz_T;=5P2(bdGI>h6aev_MZZquuU!pzQk*G-`~}}rC^v^I*<88h zgHSBoq|GWg5Vl*M1it8vOA7;lL%~00`Ox#e1DPhWepV`B90Spm!NmDy0Oe^+@KKwp ziksOlg99n34}lE9uvH7@d@tT`#<`ERcQQfB>@j9jEUJXW#R3r${qzpUG;y;CVe)kb z;|5|Z?;7BkYDvtVn&kgycXMY(Fe%E>jI_QMAyYt{Y@-rze;hD&TFZtN10YtW>1at2 zrF@}4VgF`?PJIsPT5K|{{p`J|}p-Ux z=(O)jc@@QwT}!143AUn2!CEn zb7*8>mQPcQqE1Ts3`Ic+Z}m<Fm0Wj+H{z7gYEe@R?bnv0i;W$0=9e>^y%7?V~_DcIpsj;tij`~!a_&6KCms1=)QM1C%%qG-`r zfrsOKJfN}b;tn>krF!&V5{l>kWYyRoB@WZ4p7|K``*rFU>`5*-?1HzvXV&q4DWB!d zoxk~4|GCee96ouv9w-Vu;(Gx>XO(e@+}r7Xor1!SH@z}DY$ff&Wu(X+AT*vN?2M9; z^1(Xo4f%b3$8fqnY}jychEDu>nC^dyx0f+y0zYY`04GciJ6^2_T-8Nk#`ig?k5)8( z;Ipu7n)`Ty%<1&r*9;KPj0>p?fd%`>Z$@pB93et$M=_)K88<uw5cgrVP!D~h*%{InXFYwGRkMLJD(!Cb}?{gRP9P)UIe{SPNCTUa|;s3M7F85i%mw-kroJ*pdlaoAH6MoG4soiV>Q2Da^3% zEx8=y7ghp{!G0lz|7I8(la3(LRt5m7*CwVx%OIw7EYo$VOsD1`+k~Klsx#@*6GqW< z7Slt-Ew+oq#TR>=YbL2zOgvE!Ykc|EMw%x*peab$w}=ZQ!k2Nl?IXXB3HrK*sK@g zJ(>azLq7B!AJ)zCMehV#NZ6S={wA{Dt`QbiBmG7H>ZB*$Sm|4y?kODD`9`caV|g&n z?jc~>{Qn@i ziDnb(;^QXq!Wy}@Fu>tm_#5B$a6(0B=*w)FZbH3qbD^)B4~Veymp0l{D4s%6t^m{L zmYqab#vz@L>5RMTAdeR22STNtU@F+5Kk}COC6)jwR{%m3FTBVAap!E%Bw}vLDk$g{ zE`btV4Pw!`UtL5X{O_Nf9zNXA{qyrHHN@HY=^s@UnY)=xd(XD$B$F1>Y zY732}pc{~6jXXXUofEO_)Kh?9-H?J+G6J}=m=?DRC=Qwiw4NJNvFNOfs9-!t!ScoRxqBzpe`qPD#qMGDINK;FzlLDw9_gn z1GGR5_ICHFle z5p6_}4NubgDY06t$B+mOLeZEj#R0Py8zuH_1jybHKc%n{TgY9~_`Det8CiC(h540o zF_CE)=xirErXdarwM&CLTV;MChyG1v;$k$0?g$V%p|;8#q&OAOGJuI1&i zvm^G;4c=$QGrM6hU*ph8R%XC}4qDRMLypo8jJ0n}VX)_rB4f&1obNk_g=m}%Le`N& zcwrUkFCG{*gQ7-2vt@jzayk&9x;28`&ycW*1?|UV?v;~x-Py_Oj^iV zZ5h6bWXwjBFTw3h8+0=Kz*4hA1qBwK939DDoh!iu7o2}sx!#) z6ZV!F{t+;NN77ynK)amLQPOfcMD(?Q38|11MZk>1SM}VmNLC)3F<;7>c{j$1?sZhHH9e3rz&dCAYlg{BTE6 zx70D_gF6`-vW_K2gBl2&X71tg9VFh;vmo+r$HnzlLJVoWtuXxJ9F&}{xP6cl;r_z- z({m4|oc`P2{Jp1_hgVm@{3aw3a6t}H@C}QA<6S6vdtbCf0d(ib=(uRmFE;#U^ zcD*|aG2Z4FFd4|)>9xbsa%`JdBCS_nEN__Yp#xIbc*N zvdKf~tcG1DT|ikcn|uDa$0Poh?a_2PCQ?JztpYRamxw@wg9gLw9~H5-G2)A%!TSIJ z5CBO;K~%}ZF+fhsipI1D^}J$)$^svwz^BYfL8?bdWHtL{)O(bjo{8W=lo3xHtWwq( zg}d|Fgo+;C1Dy0-C?l6qbvkpmm4Z1^LqeO5cMt^}KsJeBQG0wNLb9VaSp=DQ)4Zzl zquFm8s{#+ewp2P$a0A`XBFdMSm@pUa%3r9l^dXvFphA4-$dfb*BN6;=3*#Kt9*lj8 zYPyQUQ9pZvwmg86Rwt$+X~d;ZnI9{vLo_-U^#)dgG?ppyc9O;!9rMva5QmHzbeKt3 zBYUBaqkA)>dqi7fN-~Eco}_X^#!&19!)k;;E!;RwqGCWGM_EmjY8S5sV7ezSc-CV5X5dyYDgOY-+5pjwM`}rv96pGN&T4(Z*M5(VRuZ!5bC&bi(WY=2B^si#bY4!jn)lZc3r0|ka#1L6Oy!G;n z$De-wD;cLUrzg=sVx%T*1OVAihUn12L0*)60#pu5(mP5RG!QxfsAR9T!N7W=a&cA! zY{j?`SrTcFS~?08BRH*x+$1r}W}Dae(&CzqzSYGJVlAO1RN_FCILcoEq)dmkr6Tbe z0TE@LOnHN=d9Od%JNoL!kFPfX>MW?eJD8d*575Z3C#4{=@aDSg7ES^+^^>O@JCvMm~X6JP2K zA;HvPYg3ii{*hRK7TcxnMj?a+8Gb-a+VICEHD5I_VCo0(_ z6z+^L5-*DJNdU$>Mq-~rT*gH!hWIa~cKCkagDIFSmiU;!T#|&kKFRs9?ZDT{s{r9q zDfbZtpOU$kA&ZSy(ytfs&Td8ys<>fAeQwWmSpj}#^D3u`n3{7{cIh_T^D_dU1#!r`Ed5urC)vdt<~nm%d2o&Jd%@X zCf+Gj;stu}y+qhWQ!bG;c)!vB=PnHQCz)NhD(4xz$W%QD#&F_2f{^5ahqEEj53o~W z-XYB3YW|d?kep(-vtk4GGZ9t_HH+K3M~+ zW2O%h_f#iiA)wHio|3x^jfIVIk`(geY*J55qmF1bzI4Ll{ZfRGJ|vXP^)xNgl}$mA zho%V8@SL@g?E1CFTqKR^L!~XEQpUGOKAN3)r0Zf56egzY0(g95D!}h{+NE8P5WxR+ zARaU^)lgO(6pc{Y(lj#+ zy(YAUXk-IM9>{&(ps*9y#{VIg-b^j#z%Jq$)?7$eW;$IxROC~&Als6S$s6qf7G0D? z5wH(Q&&3ar)KPS?u#o65&h{ZnGb09gZ*=rQLW)7pD59#de0-JZA&qMtvod~Va-NTC z9mSB-JP)cvQ|mMqxf{umUg)BYqYY-t>8ze4R#2$1;v+;vE0jRwDyf7Q?_+p((EWm7 za0EzKALrTezC+PmKoK}my79;AqbScYenCWDQB9=o54NL*qQ5}(+K{F-q<{PdJlV!b zwiSF&A*yv6WxlU#R6+?kb<%3pb|)7`IK7pGhE6CVU}1jVZ$+0FcuvJv8O9kRFm=7#?YX zC<9H^S0bn7lcx~XaO#rD$~0qwME-~xPWmWLT3I9VxRTSPfq_tM{lE(0{=)sm{wE*( z>2m*FG{V?Hzq;`E6&;>@M-5+K33TP}FLe3usa+V5^W}dK^1lmFDp&b&r2zc>M*dzv ze>IFzsP57A`JcG>=$_o?Bx2aXIrH~69p3xC>MHROT!s6bj_!S*l1hh=|HrYsFj&aH zeELTr|96vp83xkznZMWY==A$(2MIDAe&Aj{5IO|S6dqjp`smpshuVM1}dkVdRlPhWB$aezg+OtT107=w_C*f>rhxSxw&9v=w21jWwa4_>b zryjArqxng|#zh6xt!rI+7o!>D8zR_3_&5)!0mlc8Sp9e6=|gM&XvwHEx!Zjn@0~W@ zkB;OcPXRIy7UR}1`T?!`5mO?(sA3C^4_%s$VEPWSe2mR8wdj6S6jL?UhLl)C6>kT* zN|xQHtbMJ&FvR}E;GlgcCouwNhCTGD5^0H^9g<5?A+{yS108}9T>wf4bSWIM>p5m4 zpp*HWe_=@0j_fiI00y+MG1AN*0%>xJtofib$l_zShzs(< zNL@f>=(Q)JV^Zu9U$$ezZ2(o?2|{EgHiRKN>8uiDi--Z$=S zAJ(^DxU@fmY{0Q z+jMa9jmVx{%k}x6xu*vw@5tp!l8x6FKXiCT{TR9D`pn-uczBxhSEzk3dMB&*>Pgg7 z447rQ=p%)L(nqW}{nazSV{ZQldg|;?z1}?ip4o&U`4IIlMOJY7%fSiy7f}7eKDs*p zQGoxQ#Fd_b2cHk*^uA6Axmju_9KUC~*YScgDqenJ-+Gk5beoqL;S-d68Z>yCkOvon zdj2r3VyjMx1JEC?(J>Y)wT?k6>2jfGaxF8r3z?EO_D1a@Y7ik)Bbe7v9TYP5(Pn2w z?1{YSb>K6_)--{rWb_QF2y2Wt6zcB74$Y<*(SC9l2REUQx4pO@2m2=3qGgh}#D}p8m4#Zco}3dL-oEocntm z_l~|1Zn0tM=-rvuS3h^+V|nyex(CY-fZ@j9gN4h(H*NR3!Qg}!g!{CB{bYQ=%->V^ ziI?Eq&1=;ye<(j>e?wf4U_u-Sii8xiz%vg`9)Xt;Bt*JGewdc$s#9VyrYavk3a+n zmKvlNPQ-85soMoX<)=tmf3yBoT>`q1gI#o^b)jAmlE zDe~1XeEGw-P;Ma#yyxWng}=9N@93L;vPn_jIsNq$_mKP$hz%wT zr;{*OVzD3YBa|&@N)yL$2Pf|Z0Ua$)wnklkxIYQ_{ybEt@8TfbN9gZ8O70PcGV4!_ao|xcF<;rp?s%8DyRtGu7lI>MSO&b{aXS40NFi@ z!1cZxw9`WA6WWW5{bOfF^A$W|2?TVz{e!oIZQcnSexd93j=nB>o4Q20F5FWns@|+1 z`}?MDKXGdCeO*4e09E4LL5szMK*HYE>pdFFd;DhNJtkj&6dK=^{Mqdpo^0_j zqH0`#e0Kqs``t3Ohr%3FbCV(yA+<-7FtO;U5-C}tMaX+jDW)uKJXm03v@dDrup6ig z1BeBb+!-G>zXcbfTy`YNGQ8ELM)O*8nEfWEHzS{IpFr=>106>H4u)EUTs^2K3n2bU zE#*ffroPHl70ojIJ$4H}F9$x&%Wdg-H&YmOU33Y?UJB_x`Ww~V*r~%8)gIJ&s&4D+ zcn&J$UGV4)jq76*GVr>I!ttp=krq@!Uw2X-hIs%0 z5CBO;K~(Ck3gx?SEW77E>g14Ru*X@(XUrzb2AE9YS}aiM0&0Y6jX|&p4x3ISF>ker z3JmS*WOxA0iJCNX(cZO&VfHTQU?_qVViK6gx#gMokgLT)t3^vu4M&hEsKDtBOofBX z(2IRuSMSV0WX{SAQz&FR%XIPnLh*w4`$yl3`T~^eoSN8ozC$<7 zpoJBctiSx_+t8DlR0TcqJK;#b=qX(eS*V`s6IXDW!C=CQtG%S2?oUg89!`xU5eRukme-_#SVKlT9YSc=S5$>YU zBkZ|iWTAyYcdS>}k5<>8p&B2-{~^~b%MFELpCHI7zRojWCp!rFQzCkQVNYE+gX|0U z-+k|&|L~K=?TTc-tu$S6Al*2FrWHKLk<|)SibAMCz80kxy2)Q8L%k|5a|jgs~|5dY12z%JRcYgWj7c4#(*I@>5rB?~wMv2>t|i zXA%28Q0PV~M?hxTeQZ{p&mNsFEh}mnZBdyYm#uXQ)aeB`{Jjv8SHXTO56xn?-d}~$ z^Vj)rB75N&=7OF1qDdFpCHp{XZXrmNCvl_j4FtitVr(Z<9&GtIM$>$b%d;~r_B(3t z>&u;fLOD)H`F&R&gi4a z_=EMAC}}}HA|?(vvIOFrdSBH3W3Z<=AWLGvQYxSV3A4qy%W_Y|w_rIo(-h_NzwWri} z{w8vllRgL%{&Gj`@z@k+RP~)+)R^*v-idp(D8ZowU$5`vdk2Sb?_ktZkv}(S@-O%g zM*j}Xd(vvj?-CVLBF~TXr0RaefX`w{ufK|a_UnHVzF)#NpBja}qD=s^37jzSXb%O3 za32TXArj~rR<`8pCZIPgMp6X}()h&(h&>eAqS7pmRLX{KBOQJZ&glB|_VS}}4`TTc zf>HRNwE|ZI!c=IMRBux>v#=-@x3(vLSkMxkU+}y9c;mlvhC1u+bocv04w|sT&;MP> znUZj4HOK|!9e=|u#BTC+^z}FY3!gpOgk5Afg0sm{YgPv*K$1xXF~pQH`PI8PLP>1n z5DmVn7A05>#~K!iZ(x+L0+h7OZB zz!VN(eSy<`Q9`z6MD-9bYdhGtn#`3d!_<^WX_Rj{>Oex-JBZeA$Iu%!0;w>hC`fp{ z6ICbySQ|_!Lo(gRWD`c1(iRcEkgP!vi=FDox7Dt3rQj1rtcKSvhDzS!mo$!Jc2sB@(#rgDY{iBg{;ME9xg03~s8y70z- z>FEFXUt63ixo$sL{ZIbKSO2!Y&b|Au{Lk5y|<`>hf;s+aYXX$50AYi=@v8*RT! z?NrxNavHYirkv2m!C>MhW-q?LsjJ*%3HUbPCFWR3a`Z!#+F}i92W} zs4I~xWKUuIOw#~1&|r}YVQ4hA#k=9{)y>8G&L3A01 z#n4SUqRJO{we?qs8z40%P3ZwDX)S+LwMKmsi;F-|dgKE<0?Ry7 zi~lCv{ylcv^^`W^>2fPE8+A%!)SYD9x5`95-H{kn%I>mvv5P)y9j8RrcMHj-18L*? zARcAU1Ev)W2M75u@PN^YBnSaZHIU<#$+SY$Zp@q}(oPwOD2`BV-GA#3lSSyl-s_8h z<)4H9@4peK!qZWzXfB%?{Ntfh|LMTq3B7xc`Gf*L24e9?$7m3@Pn=E&KLx;a7?0wgUAS|*BJ;VvzcMrYO*>A_6n=mStIUi_^T^MykbZ<|qRJfKNDBsV5x z4oNmyh^l0{tjk5+{hNScwXHy1OZBDC? zz5LrConO%5-qFSFIWHXrTrqx*+ZYF;ZiwRerVJ|?$~Iqp;h7DAI#S45Q+kya<+MxW z8BKR?z5dO9ad>_HD}5+M7O9E^u%zikx`-1)B0%PnfZXF;DPzFp!3%Dw(OIuLLe-97 zi$Lnu^;=t+tT|CwTJe8QPF}va0r5eo=#PyCK=r#oGCNZHrHCPh`IFo`m`IJ8k&)*k zBsGYTRuVCCjCL@BNIW#~rA)e8A+JS@6;XNyTOliI1WNPNF^#3tSi9DuLnR4ZLe=1y zznfaENx>PlS(0YpX{vzY`pP{m$pgbkQI>DtFn&EuSR^HZ5clRs-V^+>g+JKd6eJR_ zBGb(n_XVF|&NSRW!!QVz$xMy0P_WeEva&W2Bh5N-BF21n6NVu76~e#xuigKj{T0iN z03WUXyZ?i$e*|%h9xsmSwKl{?l$sN42lnweM~~Wku|A~VDB_Q%$-Eel&ZC7h(w*^o zO3ORw%yv^O23G9V4*Mwf!u~nzt1=632zQIXs|%6LYMMdXc1^O;F09GB^&UjSS|Yen z#dO%1DCaN!)x&>i{g-zNKmWHb{wx1mUp?O)NZjCFKl{QSSfyhBf29C;zWG4%N z1Oek)5jpNnor0Jc%v(H?D4}W3^mh@aen6||p(qp%EE#^t>Fi|IU}~c30$aO0EGYnx zB?*iZN9`a;d}t~F;{bq?ZmDy!S~91J6o|dhh1%_9>z$!+#GEn+m6S*$ArD!1VY|H1 zWtml}{-@MwiWA+g=-%$7EEn zQbLtDx=+#N9~{2^_$nM|@jVmw(nTX6tm}s_>O^l1&v}DdT##JRp}rQ$?{&Ugln?NE z?#oy5@&U50<4L~Rh7tCfbX}aZ=&Ui!th=7VR?2~7>d_&?iJrDN@Dz?HZ_pW4_;6yM z;K+o6{SCb?wkFo#ElhFm2!JU0@EyjmL0xB8Xt=SD!bdO?m-vE`cK~tEhmK_P3;~TlAUE=;U`~K!25}(n(iSdr_K*~NkRfyhR{5Srf|AW8Uy?ed+!~f|DBZrD70@UM4(PzfEMC{D< z;m;T_Ui%yx6Z(>*-^3xxi$Y?y2Ne8v+BhMMj1e^$-TL{~>$ybl&Bf%z^w+L%KE(=A^_15~9g$s}Wj*E8PRGkaL#k!qZ!72J$>=*`qxiL1+>yVzea|HbO>;U^#uT5Rf9}^k*xlnLVwCl-e;tI*4oP! zTx2|JtQt(QJTnGv`3~BG5kl^_%oA09nu4-&iVnq;@Wr}_mvIY1y78T-MIDOY&u9Hh zdw=5rRH6Ip|Lh+AlNAcThq~1?k*prs$iU#liQW+IQg#VN&cfIN;VL3Xo+p-I;lku- z^oYj5gm@5j%mQ&|Wa*DXzLTCP(vki*e}DDTe{b=3-|!jdpKbonfB*I$cJZDZR1zcp z7nlE+zuUilqL4nsuE)W6EimrYmVp|X(a0#o*yv{ss59GecSk%+#&qMK?EJ&PsYCMR z@kE6>wIFn2uZ=a@KIx|L+eyZM{eN}+_P@6OFTdOE^>BMO{OkXRo4@}FD*uERy1ISB zR3QFiIol+2%dn))H!lvGG;TOc*%!rPB}QB)6J))C{GDq#&~EbqQa7fqus$RiH8Mz2 z`7ED*mu-v6DSIcfGbR@B1^qpR&VXL1!bZ)$@_+k7@kmSl5abehxx|xpe#^@-zNhek zoTTCbh_vzd6gs*0B4)S@>zhv;w8j2wnMr}Py8h4sTONK@_VEkKw7UGId+y}0bSlXd z?DpbkuHOCQZ|ZjznA$Ao$zMIAH*f_bgl;Z==<k?Nqki$D=jzPq z9XdGuPZ)jV^RLf?o;vwmus%P3Kkgra*PGwR49;+E@>9q7`xL$2+&}&uNcu6rzwq}O z?jL_oCym_d;pQUfWyW8H9B_FD=jc14jD$+8_Z^(?XrIU#=bH<@itfG03T!_FyMp?Y zcW-a^WXFv16eO#SSq#CLX~O%I+dL$a6AD`h)0@VQ%BNbpn}E&W{DqtKGenA^X}!%)3j#`{OFZC2+fpUF-2MOmX#5aZ2-`neSUtM3mkE;g&R z^Em#*fmyK`$PsLD^iH_%Fm7&f^?IXldHBts zpv0BpWdF_8&qDs2Azzo9olLv>wKv)h-emWLZi1rKjT2tD*n5M``~N?Ae*$((avTJr z5mo2>+r77Muj)-wx76xIYDvZ$7;k`W4C66224-x7u?-B2**p*PF~B^)U}k^^AAQ>^(N}bi*jz(eLBz;v{ zk){vZe9+Lkzmz0Ev8_BT!#($8urOB^+1j)NfdF-hBVQslLi^Ad$D`SnA zE?qhAndOnI=VynOEEqFxOCQ$}D!1rrJ$mR_M3Wvz<|)PM6Z8bK_rLkAeOz*9*KH51 zz7o&=zUo=or)m0-Ob=p%$bV9Jo}`#}kaSPD`If*=N~x?iF&5dLY__UJ)Chqe<7Xcq z{@5QKzD@Z<;+HgMi)Or|bBmCZqk09D^_c3ri=%rU5_EBb@Rhi@$m*Zer9%LMxs&pA zod&WtgFxp5#F}TToArCD>dVHo+;od99{$3gxb_!iw5dj;oAa}}g#ssKMeRt{prNzX zbt#h)%~z%&`5~$A2V57D`Nm`+v3{d=x|Ai^nrrv^g!INIBpcS{oRzSi4@=|$Om*M) zuabC=;vISQUJhC}z^bpl%3ij!(dD(m15wxdRoY%3)7RZ4aqYVtdoD+m?5|eoS?)bM zes$2_!V|~3WHII7)iiYLS8;q+X|-8!KQY2t?cd7@vb=CK@=hKf@BP7RZ4qwKZNu5g z`>Dh!R5*O@@2J<_o8!nHzMjJ$@%Esik8(V})zL#p66mR2q_$FEIP}*+Ln~(716%P(mPJh>! zWp(t3=I4(F*UJ*umT#=bX?^&R$3X-sZ_)b?N%SAq?`f;~A3*ja6vVl@j*C5T@W4yg zMn&5XGDj?v#X1Bw$*NsQpR{M7ni60Wcs%5AU%K|q;A=!54+2+OkY;L0PVKbmb93!q zdiD176TCTQRhwkzTr_#syHnE6d>ixKPz1FQ6arSzMq6Qhc-!XqX{aQ!az;d==mHRD zB1sN}z4g&1OMwx{g9U9*L0;w|+0jE~s+Z&N$>1YLvAa~_8+ARXpPO*hBmlQ}`?1?tenndFUVc*%CbA1Dst0#2<=)lx zH|JhVvX@AQkz@Mh5-hh9X6m|v61Eyj6gaE&TrTR?Wh_{Ee$<<*lXXz}0o?~YpU&&0 z5EPub3Q5f$!vf&xs@TDk?a@eTp&ZR4)S1dca;4p>4)DDe)_B&J@MWgcn2YzFIJ3wUa@DAkrhhc6Nf8y`N$2S z=a~oHGz7dTOG~AWBmM+C7gz|+lu~8hCy6Fp$FiodfKAC!h+&^Ztn&nNUk+|SS#Qt9 zkugS@(9Fk@l0;6?_HI1`iKBOP$^|NulSO3hgH8!af;2g6EOOB%{hv z_4i>7fp8XKzf4_f!xa6~VSl;rc!=$N8ndhM77`Oq>YxA`<=(9#_ktQ8ncDB&Sxm&f z?h@nh`_+^N3L=su{WYaNI)Erky7j^9h!;{paB5|F{sz`B02tixqh5PBhmxVrT2Z-H zS&!t2)1HC{uIR6P=S=><;K?@9FzX=~|S@3R%H!9R+P^{%f&YUODFL9X)d+>f~BOZue^~_NSA5TDu2jZr~9?^+>(>ad)Mi;kHAHCAK%D@ zX0bNy)jFo2H)U^Q5Dc((%@ZZ_1Me&;hxfKBn&-zDf2{`Aknv zUmv^)I8LpsSrM20-r=JxIyGj7FmLbZ&7h(*=3gJZ1xnziYTz&$wMTCSS=boJAB94J zlk;m3G;Z~KhdGF7SrcLlRG^$$@?fE`&OgqFjK0Inpn|uv0n3u;5o}f;Pp;2xDy_Z z4pH{q%cSs(KTf)$w`kMnRo`x&?TiGdzt)Pkj57$@ZvbQfXttwwCz|c1=$);+z31!u zhr-5!CqJ6*zSFt@zU8y_@SkmXy^1tivs${aID?ZqqexUWn7Iy`GuXaH1$FL~lJhg< zYmo)wZQ}1kxzLuv)n!x(P@0VFl+lJp9DW#O`zH?_0A^9$|$@}|I7CbQgz~`0Ip=;X)WEs*A9uJ64Y_oG5d9t z6|@q9dFl}K%)jC_l;IbtrIpM;7rlbE2VSb!e6zFaLM=Rso07Y zHs~KxvrqbB=z>&r6W5Q1m-PQCOYEoQG}(+nqzyp%U&#S`9lVlxI30p95{Z>i4y z@Q>y(b3gh5iS-oYWvM&L`G-uefeX>D=s}*95*Cu^g*WTeNmSdTGv%#(q?^vA@V#4K zdP`nC==w{<;odkAoJ>C&-cjE@%BuwHwUtHBx?VOou&3!%`N>!>4^h8!5Vv z8ouiNq=#S1SEHTqaL%8)y1f^>qTo#f_NzS{u5({e(?muHuxRaIkUMJIO#FP)uNYW( zhG1*aTo1gVYqU@9IKB1+ys~QwrA=$!2k)Y;Wx(Do+?7+~xay{H5?Rt!aA_|GQD<6% ztjn0MtG%n6(^4h*wS=KBLP3nkA_04m8qtEyH5#__$0Z?Zsj^|F=~*Meg;;*} ziWZ_jQmKj!23-FG=va=Q?d*t<-`WNX7vnAJThZ&TG}L*8q1hCp2-Gw(Eb^u^PYcon zuY5JZD_piF4(4`&SUR;w$eAn;My*yax>gsLjr2V(A_ae%d0t5d^a$21B^&yTR~Vr} z=$2OR%5q&E3D)K;zkMu{g+CyQ6tjG0?IHEm6&?laq4GC@)sQ*! zJ9(DZS_S2w97_fbnzCBYB8(KuRliYKEM5*d9sdq!eXiJPB@dBxTaRS#ZKUJCH$sb! zsz58M1U5&DPh^b_&AAu^4tw%o!gpZz9*-1AR~Lv<>a^W++~PdL${Dm}$RZNtu)LU% zTr?GHZi-J;2HKD6T}ZFHS;)*rgEv&I8zQJap-L--bh1GJsGMPp7({Uw^78;f!kSCH zf*CYC^HZ;PWIklx@H$IEJ#3uQL3AkcSb?l$Xkbj%k>I6T4y$CQ&`X_WH06oZY~v?Z zj{$Fm9c}3Vs*>NgFM#TAGDZ`DL|u>-KxjIz=P^K64pSwk)rObOI28q2P;*5A zGzHy#H`ZDLE#-Py*Er|5+P}IzeQthDAqEW7=eavt?}{lV#OMNWB6cX=-n4y5HIbcq zjKS1Ufo@Gf=6?={l!k=1u3d}_5SF5BdSLG@YC7_6U(Vl2e1BCL1i?V8;4o&(PaFzE z)D6w$CzdxPby~t#@yZy1!)uGcTL73qXTMQN(fq{gg2hcIry_Ljj1w;ePu+kj8nauF zOqg&!4Cnkt?5@j9S`KPckxqii$dI#=`)tG_WA-35l3ol=E~GujDGaSO zvBk*R3!#D6al9b29$t^C(8hC0ZKz-U-m1<`A4w;5bIF97=QQsisgU&HGYgj_%LG`{ zl1>m>e)iar634a&&ogFpJ%p-7A1W2aN1wKS^PQnDJ)eH)yN83%?jJqSl!N{ke_i)o z-2I$$p1l)$Q8Y7JGdC@aY(-qfywYA#S zysa~-$?t1b&G8DdjUEH{0-Wf*6G$lQM`@cU+2y=0;EpN;qt;OrX_YV{*Cy3FiV|k# zj^=q_DG;b_Jf_vRfqOI0cCFSo6{9es2$b!3p1V8tkm9W}^ca&yGjJlUU4Inhl(r9X z_Or~?X+hI8#3K5D*lhyp4+HbcxaSMhNnqNZf-<4t+*5fS6r`FOHUQHZ&5+F$o#R!5 zR`8}3yBI@7-L zDpGypOjRYVZ;dT&BM^fLye*QkA1c)*{VdKo)_AFYNnQ(9y^I&N8Cf-G6=_+jUL2gg zRcEqcVC~`vE-#ElpgJ90dmC{qS2}AEVx9Lllad>s&(*({`ApT20-usk?UXmmx};J7U0~on5Omj zfA8vk&rAQ{8&3ZO58{d=vr{uoJt_F*U$y%3U(`K#6YQ-^N`jYOp!YnM{`bE*{5(!A zxqRMzr;f{1{Fq+~Jpoc<

RJXF92TE|L(8t zzWfoqTo`ipvGgbZ;O3u``6vs9*Y&^cmv*1^5Z-?)Ufy>IXQ%Ychv>(Da{Fh0XpnBj zzExHc{QYk___v=QsJx!O^LL+qKii<_(c+sz^gZ%nbQ+v67owb%+F5J;tn;Ol*rB^CY0S86^_~0S$<_M zdryNuRZlS{$4dmhWXAD|_@sb0)Paq(C5!R_z0QFG^Dul%_O1ByrPBvrL)Eowyj;1l@TI9^?%9zO0^QgI}41c}ta zm-7!r!g72>wd#%9u9JpM=06(@j^!`+r1?SdGOJ4DI$8LvUa^dk&-CmlN;2Zj!@y@W z`_L@I8Gz#;#xZwU#OQ}>1USX*$=iS{IC3h9>FX;o_U@nDjOpM12I~<*RFo+o6r8(+stTSU$Xwzuk7wSsz2!9^3C|^pV$B8&*=XCU*G;kn7>$dBg`X((vVYvNkp4n zm$^KN&y3OZ!J={csi-Ukq85@2bD~pxD#lYRFp;K(0{jL2U;WMfLxCrIm*F;*Bjf+~ z{G!#jd`17cch47LZy)b|5P!{sdtdrXhVS}|XMgYG0+x*thFHW3;~RWwlR z?w~rIY&<8A$hfyXOQi$x`qSSO)IIVCoBzgzF4bM7L4 zt^DOHS01!WG+y8;g01WGAn!)k6z6WNdI&;ux*-R(YkOAl_bk$M~b{1;$Xnr1>8O znk=f9cbTvPYYLJiAutx%8H7URi#jAJ<23GWa=l^on15Z!YiALxmo9 zVs5Uz4{`{b2OxSw$NbknnBg4WubxGDvps$S%3e8eBe&!~`Dm`){k!CQ5WMN^8Ok2) zJ3=$d?}yEES=8apL96EUw8?P_d%RrZ%>)&j%(RSSo0G>2BJs z;|fdVA%^yCIsnAO=MGsBacU%)xdg!o6MNnpx=li`^B5f(4>!uME;v8Oe_ytK>s9b; zoIC!p^7_ZN@BTIY=lCmp^XK>7V<~BO>Us&use|X4j^6o!B%q57Pzv5=m2~zqpwj+} zzjpmcz6dYF@^y$``CC?dZ=0kScW9?agw36VrwzCR#2b)isG4#td#{cwf*8Nlx8@e9 zsg|_)p{9l&XoZ1G!9=0@V%EO@^WWG#G_|q1vcw=`SpC7@*nj2`oO@lqxBp%L!`@SW zc=HoNlrH0&VMrD|ovaJA)d@3~`ptR7gunK89{%pn#=Y}tVJ}o6g14))r0%?J=OGR# zLs)o9Q`T;z{f)q_D|1@`)%it`yFLI6enV8hoGOsWlPWx7X#AvFrG&B~cAyBG*!bBV z=db%Cqm##sqo5>?Q>b$XOv5JJOls{!-SzRWnLw*K=IMOn+d{n16e{=<%aBQgl&eJzMnn(Hm>Lw8{1ZV>}4Qc_^%Y8^Hw?2J=5 zi#XnrA4bRU*yz`$_9^FoH|4#<50&&t=X)`i>kS;IQ2CSNU1j^>;Qky((DA12$%k|L z{(WHX?(BC=+tVlV{KVcpMKg#7A{w@*PnDhhuYwXp-t(V5o%4?_E$r3FNm*#-_+Gf~ zZ_@2P+WWUJX64ymV!#3?Z+JU{tn&_v@>9H-)J=B$^&ISq^^hn}5<6X;y<9tC)uZMI z%@5T&LUOYZ$uwNGx>56&bhOgxAi1QZBx0fbkeu1mfxIzDv$7r%KfT{n}%{OKD2UKemf7lnTfD6>!?h+*KhSDnl+q&Z>i6h_ewGg+^E zIzD)LnJfgFmS+QJ?BnQk{#~!VyZhhsO~ZftmlNBp=w{Ibyt;de6~o=5y-?c%CNPdo8E4oE+ut z<}}AC?Dp^1(;?YnNt@%JFYyjv?|5_is24^(m`1&jWASkAl74pljvUu;bf(bS`JMI4 zW257&>`j)*CBb_=$IV@h#u=>w-fh-vspvbZ@T$IhBSFM#dh-$WVe$W?o_%M5^9X?A zg@U)}<^JHIlHd4T^mr=z9eS^R*ZU7T-ok&k6Ewf#WxFCfM}|S|kjBslp~(z$*({ZIsADuIpC&5>E)?+#q)A1CCRuTlxiYUqaOo$1(E3&6s{r;uuv$ zc~M~i01yC4L_t&`tg7+JzfZh$Xx#p!!v0X<3d_co!pT@b>H5OF|dv%;w{Y&oYr68SWBXU2%`48*rls zWFK@0;dt}kwQYvOu#Fs`sy_l>_lH&fm|L1^G#rf-@D40V*2{uP70Panzd_qEBY8EU zZs}zL*#A~sU9$7I+yC5ZB^|xzM+S}g1wZ^#>34ium_feUed!nC5B*y`O@w4@%M_=H zJV3`NHR^q#Yjcgz$C?oaT+e?NTZ52qjhAUyba{crt6aAxCSYg^apr+;er zTR)P1;)A(#U75JN=NGPi->>aI;}!>a_ghxqfM?%Z{J2R6I#ayHQd^!gGwWV<4R9;( zVD)={X&n)LL;vdkef$UhMG~8G{P()`Z~xVMU;UZ9l2n31abU}?kRqzfM`AwWDvAxQ zyyoTut#cH{27}LP0?nU~mS3HvR$TQI>Ub}}aa*axeEwUJhhbWgqh1t4cf5c!z2}WVxCW>LOr`WrO|x zYqFKy)p$oewg0+eq)H7dUgQvCyxd4QnXi6z>7f#uvd74i9N)L-BUqWQuKejaR1L2n z7CLh^cWcu-UQtCK6*zzaZ~p6gQ2ET|&U&*js~5nUe^o-o863e2qL)!ORvTW{<2Bx_ zR!r}=c(a|5Jl>(v}Nja#NI_=qd^xLX{q+t1AJ9B@JW2_90JPJ|wV> zaiy}$7=k!{A<81L-04>bkAPXXE?G(=E4>H6JVq#uRaGi_evXqQ#+m0IeRA2MEpWnG z04%<}1}~KSZ*K6iZL~go3qjxfhMEC9L{~j}1TYb^{FXX+pF;!U#MtjbnlloC__0f2c=q51vPPO*74!AthJf0^YBkrS-lx4$HYBUN&%Tiry$O$r4V<=*W;G_8Xiyjt@I zjcIWTu_l+}6ee9W=hUwbUnj9K0-MwrYV$)rnDh}fdVIlTae}d9NMh+O~GDHyP*XjMC60`FyB zX*h!rHS3eQw2=^)5Kuul)3YJwreNWcr)MYpbs&dmb!C5vDg&G)IAwvq4XT6Vxq-T? z_+N)*7$k|}(2@M?6*19U{_l9$glvkUX=UU?LlI(r!^=4Y0b;<*_$&kC1t(Dn>i8fa z$7`+{tnw?(XsTH7N^*zTOKyc=`9V$Xz@>=S{-KoDNi_8=`K>t0LUi|*TJ(WSRer_W zS)Ev^Lmz{P*YZ#B8ukXSp|wi$+5lsa+)MMf++ zJ1c9IvokMn@fmhs^KhN#ne@LDix8}`m;)bA|Ks3MMf?nb9+OpZuxekNg`xu18}vcHVQw`}cp(<~NQOp*3$7VVzdU z)7u~UK%@e1do2p*JLyYPzBR2pG`Midc{wHHr{k6CUHZU3khUqftV#Uz>HqpqKms31 z4p3rwHLM8%Ujypcy`w?sXeI@tY}Q_EM7!9T>1fs)G4iYxvr+O@V%de0 z3wOsT>ZI6He!#GYM}9p-N z^LTLqFD~H4Sd5Y!@K!Ep$BU6ps7NpmoLm&)Q1~R$FA38%c_B-rvr={~UOE@A>-8G1 zgQps@RdUTH1szxrD&FM*Ha)YhyTB{y8B>Vi1D=8v*NWD%bSDIBV(b!o%iibS7cr;D zesoI^oJ0se^M4Fa{=${-VcqY3@mIm$_`BdXi>(B!3NdqOmz!3S@kHl&N7$d!J#%A`z-`7n@2n^is*iKJ=#S)>1^(eRo8~6JUr2xBuO(@=RukH$%CzY46aT>~ z(%Kn@BEjE&27Y@0dD|@`fm<$%#!#3r!ljdVSZ3;++(iP9(~AJXhueMTZ|m@XAJhd< zxQ$`~gLP+eCWEq<7j;i+$UwhRvQUkgqkx`yaCb#uX@_V*s1UmK*b-c9#UY@2NuNV z6t5z*7Y$x$3OkDz7VuUCLtUM7c%i{t=ckr?j-F$cW z8N!e2zWxGgxH^!u$!MsGUYnWZ+K)|9stu`1IHScWjF@o_FD&7O9e5p#d{I43@m4S6 zR2s%?VPIN=oyS|{uDMjbMfBKREnceF1{12923=9rqG_MJj+}*68<;DHRT5xL8HObd zRxt2|z%5Z2*9+DaS@V!0ti`I3e>O6*L>|TJK)57+)))3MJRv+e{MENR2Eq6Cv!8it zb$2+B?a@!$!@svd?|o7_gt-dReO@l-2&>iPZR9S-$6+E_^F&7wL{OW}d>Y4pLaUUV4)L zv4}jOJQ6KKZPcb)=IHKzsDINv-Dlp9cin}zU52|ZdAn&noF?mX%Uv(6F9|7dVwG0h}Nfpej`$EXY7N zA5hp2VK7kR^!2qZX+;i}jKRW(H!^>3|r;wRHSs|Iw? zQ>J++@0?QA=ZT5Ab zM!1powf7s|R8362=dsNbs!I|YTj~Gx^Xbd(3OVoT|8K84`*R-yU7u8_#M#{(f-bNR zqNlvh8`KEE7u-_6eU84LG^mZnqKD|D{RF)Pai21|n4a_JXcl*pkm-Rr$tM|88B_Aq za8j2RX}d0*<+e~ZVdzYQ`zz%sLGAq!G|73RdMm-q>b@sGyyZD)$&bPHOhE^~s{but z-hbi4c=uJ@o1254UgC>GEC&}-BjE)J`JiE@CX-hV(&leHw*Jh|j=1su&)WNApT76a zPi}wg-=6)of4Kb!*OYNpF=?HMKhj>=50zFxRXd2r2UyjH0)lGEU}t%bwp0@zp*5x| zCr#dp2%#X391PXYJ!>N(?t=+MrhdTWGFq9*y+{R)x5eQ5(PDF>>#9NuUa}=&${x`p zl3um0((ZljI4MyYj~3$W33t)O!Ecx5Y#zRLSVVev^XF5trf~Fm9)FyH zyfSMF>r1OX8e%YGBQM=k8aBsIb0?BJ6bVHtP^30fXyKqrZG1Xrc5RQ}J^s^LLcP&& z{I2cEyLj&k>Y9rj-tPEa!^yj}Hc=E{4tw{`?eROSAyjeKY_Gp#d;AVm`}Aw zY>wX{c)@_t_S$2c>yI&91j7rPYmaTOKUP%)!;$gE501yC4L_t&=!AW^@IIXvBjvr&Y+VSQ%Oz+s9ymO+{#{BQup1ilhTmB8l?;DQa zk0DX=pL}q8@*!@w;T5IRj|^u|AdoH%;eyj}_Eb9isJC;4leSN%&C_}kjo}>!FwfEU zc~qyF;Z4I!ln+8|;EYY>`dG)U{Da5APX5Qr~^Xf9-0%vwTyL5IKp? zQlo1ADmm`ETIG1@Me@Q16%TWdmcj;vDArktC5DJt8X!jABv%Px%YkIXa*5As8;aWX z6foNn$|80uVVRR+oF^ErQ`j0*`$t{*B<@AU%CA4-?)B6AmofB$g( zeHzmiqy$;ty>}G#%}8a;uo?AZ(O(tbkpHQt=R=qWBQD&8FT z_iZ^;p|3boysQV+gXeg&9(=n?Kf` zjLva}O8mmr!Gm6QPA_qZLcAhg9JSp_oWcil+(E9+Jc_gQ62CCO*_Jp->yqvc!5sIf zq;s4?40w;xKKGT4TSu_@t<&=_g<~5 z{G-K8UD->;czHHrOdl;ml=xMs4LrxedOyc01l@2|?!$2U;T-R9|FwFiujJ41v_?PY z`tC{+-;i653_ z^M2j7$MS8;>>qnNL#uOMHLZQ#6^7vE#@tu5kltARRTlFyx=+Cz&={Q!Wkns3A7MrC zjlXntTZGm3U-kdE{8ezF>%NE9pPo*B+NlZhr>2kCV_w|QvOjPs=CK%NWj1~GeF~mk zsywv-6PT3Gx=8L0$n`P_RVYV>qd7Ndwn~Z5IwG;^*r$aWOE0l^MmP@cDHJWG;!V~R zV$tZ`Oz{gq304vYX;Uk;;7_s`k6DnX8B`RGzdz?U@BM+#n~N8{fwK)g^&CC@Bt3Pt z`?5FGO+AsppREICf%#eHLO-gvPkmP7nCegdPuHIOy@%iM)^3KLqwe#5#o_0D{@(Zh zmFs`%M}^Beu(ufwGViQ3bFV4!#03tKwMKGf+o)b3UoC*q3qf^1^^erZ4`A#)(cB8c zQS#+4>|_oFRN@pS>i5|xuV0NmjceIeygI4}W;sq_kz+U6T4R>y!`ZVO)S94o>{+gk zBj+K@NnT&9XiNN8>i6yj4woc0M;cBaAF;0vUPBxOMz}F-PCt<27v`YWo$)irK|FqM zj#GH>`mA@Po`K`nLWxr-aSa78>k%sYs3#+zSnuRu*9J%Pvz~gBOd<*12xrAi7rZ$h zA(i;PE3ewLRlWI0iEFD>n^@1%TH*w2lW7plf<54;~g;7TwXxL&Tvi51*-C+?;>?jHg{@hcDlhuR}AWxs0>-5^= zBr|Uwr;uvHZ2qFjvv)MbII)Gz9`ogRJ{eQp^Xu)&GiS%2&~+}pjuKorP+>u7H8LZS zthtwFaoXX4uwLyyP*SQXkFkQ+2X7$K_Fr(}G z5|YLb6;lTFtE;5r^O%nPrDBBYu)xxB-+%CvtoB=>uPKpX`hvT`ir91qc{7_n*X5K3 zntp@TpQZH06<-RK_CNnVc=^|>x?l4s{PbfN%dD0Ps7VUVf~Pj`>_ueu8(@?mZ0^m& zi(+DqAw}2otUAu;05u{wH$UrL&&BldJV8C4=LW^*5qrG3ou&nUBr1G#u{}eW8ceoW zAXtP1U$Ea;gfBgn{>_hXfB2_{pZ&PB>x}9f4=SUOoL+}H#VdcclrwoiHNW+U^gVy} z+V|YQ{sX^Z?^is~?e&vSF7;piJ1*URbnRRInH-W=LzV6TbJ(y}kXi=oaiD}$*)c$# zO?R$iO$W#pu7FIP@f74zP8`SB$t4MC@>nxrA-VcRa;U6q6l%H}nxh=MncEIU7D$}J zpm!KfmXmh?)OxD4r6N~TZExfZ{lRPUgl1fwT;$&R;66Dn&)Q*mw>o%$RcF*YCBERO z*9HwVVQUz=y-RPTlzjTt^FPX=Z6X{f@p1EsA4b3HsiN03j-a{#Sq~;Xc3D^QjqEvm zgjlZyygBS6>)D$AoPPK)q!3?_8QNJq!p`cisBzZg$qqLXYDc`IduS}L9S_L3bNu5s zF#kt0V;sZDx?P9@g(JOMiZ?LtXA}ow6lOr<7gg@_u=gM)9X)S2;(e`$qb89m+X(`Fwi^) z%tt8^ukFRtayl?&-yX{cg5V?{jTnwoSXhjwc-e_=Q zq1Oi`exWE0^7O+u&f=9gg5-t;O8!Sd;Alf)yb`|m>=8Zk4=a@?jzEk8v# zOg|u0d$>k=-e84PP?_WX;p@D-p`WZqp(8AgCIa?3o|aG;UFcm7$|}to-AgbMdaDcQ z*faX8XqqmhcikoZ<#2NTZr^}Zg#aJm2GwM~ZLr3xgBH)JMob_=bwHD@KfE@jNE8r2 zO#)PTV_EDLsb)3LO_aW2{ni+s@P@DMEnm8#w{1cFYtqHqY-%GVxf?0;DD1U4gJ~66 zpkK&tV)Au3{^7dmL;$$&7(@D!bA^9yzk~dZKv~OittFb%ea+N`{tvuucJ- zJcyt9kGgRcg-9r!RfC`vP_44=K;d6h6iPhK?Yv&&8b;O>IbTXw$@F~aK#9-i_AjFy<$Q_1i`C8=61|gb zTEkREj%yg>3u?OPH9%8he9JyXNCC9SJWls6W^h=~+FC$iEXNy8PKxC3I6by3(Rcd~ zl~&m`c#A!dnzf$e&pek%Ls?0`m6ZqbO>0Dj`O@mbs)dWOLom5hOmWkI8f69ZUHpRA z=H%Ivmp&Ybe>rOk>0%kU_n5>=u}4ZF%Y?lKyv8W7r%NkTgp9L&q^DHi4Y=T$oMbuKbo@%1tGs>1F;xTB5%rbL6M9! zQuzV`GnouZMW`j1^As{zr7VDeDdFS=OvxEC&QQJi?vV8~{or32zzn5i))jp5@kv^Oe1B^D55E8S>wnktzxpqp{-4j*U*6Pz$=Ax`T>FRF z&dR;B`3kAcRsXn9dPtp>FHsRt5LQ&JBR9A%5@V_SD^NLFix;RoiZuo3uT3tghzr<5 zIXd@D8j76T^+HXv90%1h3^fEm6p1Ks04q4IM;b<}-f_8#Mw-I$+~E2J0|dd=W7Hl2 zPK8o=Z4nU!yKf!ns`|1V&;_ck-d+DXUeP%=kmdNM9=!SgJ(XMJYTZ6G)3nBEWIV z&B4p1_Norw)+oJ(8kz&=E6Uy7X4rY4jxEpSr^0SIpwD5kLdb`K*150h=b8ra($p;1 z5q?_Pzl#8*?`D>==mRMw?izqotT)XA|J5?aQM> zkv)aU*SF=}@G{GgFiDUR;wf$qg#jR2rpsndlv9w3oz&7|6q4~A8#j$-PQ~P6Nfw-0 z#Yng<9s~1S`bR@b>;Tl&)P+p^^j9?;3l1Y*Y&k)jk{XS9feLeAt`tZTVgad^U1rO| zM}F-1n|^frr@!OU7u{hW9Wu%*GUQ<$Qo8cS7ba85eU%?C`` zOR+To01yC4L_t(Bxuv!(wbhp>lBVSh*;xp@ortt5NDNDE&>qDB0opUtDF~+NbHHUQ z1<}`*vtA>43vPE+_L$+Gea>rBuYIJ<8pkVVSp~}kYx11R9nYZ+8uiAYX`_VbeJ*`; zb9VfR&B-%LMk?N5&In<&TVx^it|f z!U|(;6mr72m07Q2oH2^z;x9r0YoF)(CLhpQr;6A5g@71espwqujWn>;_Bww}v{-196#4r9le zyv47n!4)9TeCuIUG%pHFI!7sjh!Z0WsR(R9SFt?8+WwpjeZ@2ol>CAHTxzj&!LFsycxQcGqH!bVL<~P2B}87r(-gUeO%VE1E@+Qq4CiD^9ju zf9lOltlwk-+)2+e0-0TCyBKNlp?+1{P8jTEn!NRf9^p(HP>nI~-TC<0^{0qq$vHcu z@ZHqUJpuLdE(0k4)U}+qNu}Z1Xb2v^^nq*7zGHLp(UCZF9|OpAAy>G#gZ(a0d6*8G zU&NaNA-s;bAv()GUYT2_Ff8Q-0p$NA!%%z{VFW8*@vtCHbn#aO4v`wKnxW*E??GRI z!ZGzF!zaaSa2)$kMkEH-s2xfXyijCtG{2EvX1oM%-~c}ayk1<&NDXhk*-VwvuN>2| zGv87Gp>UXQ;6*dM;7T|mN=~^z)2TQK?7?sNLGn-dR%QeROp?jKl3zk(k$mes)OoU+ z5B8)S5K3wSW3f{xeajBbDhnTI{;gD50GLW^@-oTaG_1bn;d<}u2eyCpiM#-BNOI?g zvHtmi_~#R+Klga`-QEMM-&HC+XDk2xvk#`qS9s9>`aigLcSc%JCw^G{{@=6r=IM$? zfl(+O|NGI+&pq3q+xyeMxz80al*!rBOvdLsf9d|8`(-mbTQiXDXlWJlw!~YAn=FN< zazOny7lVHdO}5*+Uw=nIT39g*!X+}!@I&GDP}t6V+a^iL@Jo83Jms7)vo$8?8wxjb z&iw3E(Bxz$WJ*eALy_-w)~nqw|9mdx2nv|m;g0pU|2O@uQ;v-ymeFy2O}Ir{ZBW`s z=2%+t@Lc@o9emr@t)&4-dj4b(6nOh#_3`sBY)N-vOQTIH=hCFj$1Ak+N&l;}?dW1E zSt!t=sXKK77ZFj!Me#n1EQ*Lh!xu&D0g=%l{X8*WOdg7q1D8>OhLOUp)T=mn zl`wF5?w^CpZEA&E(Nnb)V+EZzthicK?78l@6DdiecUmE@dJ0U<*+)gcD1rb_^%^uP zW)M9tZPx&4c?Awcy(Vz1P$YuJK6k}b=MRQ~>#2$lnVz%Jz$igM*$$g)o8ylkzwq9dp846)N2nH|p>pJ_7eD@! z8s^dL=13XN;K0V<3)$_bWYMK;x|K;!@3JFFTfiP zc|OO2QV@0l!^yiShje))PJHM5WlQ|(=#A!i z;ru!15#)5Jrip2L{T*Za`qG=?iUiDYl{o!T$*5z{&GomBfAU@}jtQev;hSrZmHcm( z_9G2oOn+NRf2-7wj<~kh-bNYDTZ_ApY}mEQK@f3$^r)=Cll&=%0Duyzro1Svub6(v z@6CJG4j=SERB@JUkKYG*+3)MCfmr6xhcZ2Ry9oqO8x5x=B+bD+y3Ue6fnoDe$RPmk zvZ0BL7B-`M3i`d9;IgX^pHEbXYPujgN@OFZPz@y>|gByU3M4!EuQ=r+4} z5O^Dt)Cz?zMW8eM-u`^44|&%?IY39hf;jX=s6j&0%AoCKUeF4pj&msH%sQfzVIzFW z!7q3f#Os}JKY3i^#gZ_QFic5HVQBbMj}5=>zThhC`_KA@E`4|6`p)0}?d|)&qL0TZ zUwPBPcm3i1@BE{)@A>IG+56dFzW?8U$@(pKz|2KTs~6_yH3CNZHXRO|F6Gc_3!@Z@%QmWubpDd$^x|%<`B1TPr5WAzs(ACVofP| zo~=mO*<kpO-Gi61Qyj6kh2+_sjNw+2`*6=)XGs+yCn9C!Z>XW6S5x)z^Lf zrT?}%geJZ3C(m3*lNu)#A174JrZi8p1)2SUQTzI{(-FLD{;l!9- zoVxyyY>JFkSw29PSS|^6b~a}@mT+}d+LQ?6G5<5;NB#P?%Hf4+I4g2;?@meJJC1Vo z$?>E9;9hC(ihVfw5ap0A_tn%n}b9FhiTl=D@e%JFM+u-+0fh8anG zFM2AikKSymNc7#c|43OMy`?r5Oi#VV=`Z?P_1dEl`7pBK`r9)-M~}KiB=qFaHtVA| zDF+ljhjKf5lTacq!5oip{I$y4I!$i^#~}s|d875sUk%4u?^}A{+q(;#hm{XPe-2@j zPS^W4pN$IfIEkZV9E|3uRs%=?(*bmia1L@QaY{L`>6W{wx)RlHCX$WJDaiwcdsw9C zUD{<9gvQS$8?2qeg)TBBbdlZ1Ztx)b2|A`TRezc;<%oAW@gf!zDgdYs_Q~1Hsus;D zs@+%7@`Nb)QrSFl+>8B5fWZ%rAObx@*gmyAdlG5Y=XiT- zYxvIGxDQKQLt>eQ1@AkHF{*K!6z_)fAHA`}Q{=Wv8vlvo8m@zemv~xZ(%xOR z-K5}6CH_{|ukRr2+=v|0Hyo$%<`Ngm?4vYnKarPktu9NHJZY1o1LnmnshBuFE5mMW1xEmt!-Bm$Hq3XyV$C#`+|MlrmJRWNk^i2Adsv@ZBp{3f!KB0 zv78ZRQ!u=Okb6=B4j1v?eE#b8KmiQH&;G+PV|Kd7#=_Y6-zSRJyLy?Um03$(|-_w=8B z+R;~5C-K4MSFgVDH;?~na6td(+v&6F16oG6LY8p4vlJ&Yt2s-ae%AjA_I&^T;eB7x zKN3va{kvA*@<&$R!auHUe>$-zObjVm%n)5`B<6K0Ytge2fMTmBbV)WPCbyyF^_!%Y z+nmKw$JLUdjq0F7U0{o6f8(*e&-=W9<90kZ$4d*ICZw2C>JW zg_7)~eQAW#_lK3Z7}K|($i?li1lu$BmMr(Oad|VyAyQ03N@pJ*p><;~clikt|8;C9 zIj~pfgl2jBk!<|!znbJsB!0Qwa-72b`zj|B4V&Yi&qh*?x3?08Ma5~uJ1rH7kKP9ICR>hDHhd}onQ{17Wu zAVaML#J~~RAz?_0waF5nk5}+aDwcG4p|sLqWle0bc!Htzg3!k2FSh_^*Ax;=9T&_% zGUF$n_@(h?t1SMS>l~d?AVVjz(Hf7IRhs9&g%|lr_K=s+Y%eQnWW^Ba!hkZ|W#@o; z5UG1%wSP}ui4%KN{AJh}t-L*yVUdRC^CgdL8$Ol%*vcw5A7xy2MW+ z38Z88!U#6UTXHy7M;*s!(l}3IxRkhtk5uuQEPswi$Z-ur-Q|!0k3^4=elXJWV1XJp z8Z&=hFRcthd(5E9M-9W+6*TF=;_bTSQa6sy1EWj-# zYh7QXB4h^ zoSBAtNMNiK(|=^Z+?!GQhjf5+^lKlj8_Wkc-~ExiJ`d&Cmf`^d4VTGArx?#8fb0ME z{rhiyBl_p}u7CSmuK%H*6Y)0o*B|(v(_i_};b%8ni<|4k?f>?j=_|jz-T}?ad%@#L zkCOL){?jM-{PBZxpaH!9-(3G4bolSz6rmM|mif<<$buRp$h;h9*Y-wq@=qRHKk`;w z#3y@%!RC(KUeTt8I?&yOIX&7b`C zv!8tHglVHWxb|N!K6di^Kfd<^_f_2BaNJ7x+``9!JuBG^9R*2BbHylov1Kx*el;}& z?K*w>?9cy+Yme)CDxG#S-YQL`x*$2(ey`}5|Ix1PvpzI65N7~IIP=h~=M}k;rX=P# zg~7~M2w3D&G6vaZrEYb#p2;t8x-N(7Co&J1X{By`d+v=Ls(HbBgjQu8hba3X9Qyrx zIeuO&1!TGXS~ot7CppB->!oRiGGKNeJivMZy`(wc`sm@@4>cGPu)LLcx*j)CSKv)I z@}{c*01yC4L_t*I4l?$x3vmZSjdaCr!SQ;@`2eVK2Rn?#EqdzVT;8q&6U0S0n_f0C z&H7jMc&9cJ37q{0fszNx5H-MdLgc3uFPqotI^l~T^Zw-P1Y{KffHfGACbHhio+Yyi zE$J7*5vXPqsm>q^X8`dtO`6zXF%=ei7eQ+*=KBhKr5*s_*-YpA-OK;s*M?6fxFi<7v9JT1)elV~}vAvhCD`YOgXWVEGq zm$*%&aShEh!QyyN(CR5%6=PAXXJ1lV#$Eu0KzhH05>p_`hu2#bYU5)*+$VB~St#VGVOwQlx~T^=n0?gd(Qb-04y^yW!57*Xh&@L%qtK_oePSB9eW79`YzUhCWKl6V-_@cY!p^eU~cm443 z@B6Ep^i9nw#j@lMO+M=EeUoQPA}E3&8tM4!f9(4Gf9~klymlTb?=q2@?xfXU7?nxq zNWDB|Uur>O2BU{f>*g%f#J)!!pK63fYrWN#moS2kv)nd7w*BGsw|)ow_1}K*X*bW4 z|IE*ve)FF?`S3TbryL_~g+rol$}d@&S%hGs*%|pk)_oNy8-jDMfgXSCq3oMwJ=u9$w5opjJTQZ(xpV2ti6QY{mFbPr&LD!nO$7u4V>;S^+%&s>xUvqhu)yAHzt?6h1GD46Dt8-UgEs z82K9}Bi&R$TezlkV}@N#3^P+?{hz}@D^I?v)4qMC&HbEWJn37pgdlpkB;7e6GzeZI zU!qBlAHgRop`s2Ne#UlYYacP@p0r7ELqe?{X1sKDT~zc>$^r}CKGLj+i`}WY*g$ua z#XY0?sx4XD)gs9%0YJA;aR)%#oxl3l&sp!suZQhV{H3D9W!V$ifR~2IKOLHlbEbdm z^k=_f^@}b!jCQ=jkI~cJzCG`Ra{#cUKhJ>2-S7JI?<8-#z(zKW|eI z)s<(=gSJkM9%n>FdZ%O^#zx(nZ~Y%${C8h__>Et@di~9~-z(B<^p2m}{PU#BBbBM! z4s8rB%87&2hzb@VK4R&$=?^p&xx~a^7|rt_V#b(-;y!>lb5hbx^8e)V*Z<_%*ZlUQ zul|hwUZa0QPk(6p_y76HcmAs_5#R)9l9)g|rIja$n;c@eqZ`Dt)5p2iupDFY4`29o zAK3r3U$XxNkFH*SQ{^c{PUH>=lE_K10SS>zcb`k_D4a5?ps4u8y zjMrr0hdsN#aRae_uHno)@9-?f=X2m_6-TO6HWIcD2Bo(W0a4D-gq{XY#NJg9oRWCf zkd`Qe=;~7FyiiDVo`@$#2(RUB2Q7eAE!D+<0>{1v zP}4B*!9eQCl*&z|Kg*VSc16F9@fVS-6d3u*)=D-u(uUF#CH4u6zd=PFOD5ZL1QJ`7 zdq9RE8-u`Pf;kB6mekJS&Hu$rt;auPx2o4rU81^~EnF0|v44fIx)ml;C0-cbip5%t zC$kQ84qFWuzKe^X)5<9AMpRwrW>!w^%G&v^jJiv7HsG)N>`Km!p)aa-bO-k++ok*L z9L}Hi1$@u(s7qpB0!8GMwB3X5QRr?$JkaF`p$~emfZABWaECxYR$K{8(G+s|U=12; zFVW0bHB3d>(xcE8zpB$)`j=>!9lX#vMf>3mJw!Z z`NqDbbE)#uRoTyn9~UR^v`txChP)O4JC<{@FF)t+*yRqT=VU{))9HRjQlUif5ocwZ zV8zNJ^-uwOi8>kZ3*L#gv_)_o_=y25-&sE7JMU|`m^Xc^}v`%1$-_HP5%Leq>w-%fuK=tleYm* zfH0RHd^$}V*oD1<6X>^ZFG|~_?9I10X98S=o=}$H{oDQ3T)OzQXu=cNQFlh>GCu*} z!qB{qZj1(_-@S-)^bl11so~_w;uA zd_)zsAT!x=4_I}Nu{?Xyq?oSGjdand;al*VJz|7os1zF3^a!ybQ=~+4v^2=i85-iZ zZen4e$@E%HLqLPG&?@>ZBi0D!n-Rjq_1S=giACHtuofoxHy9Zo?{iJNh1Av`$VkY_ zMG%YJLLgj%?^Q?Mh^_s|U4ZKp8~P^z!Kr4FoF~%D0M?YT7z8ZhcnbvQVK*O%a<)M% zsiaA|a87^-6#W;)OtBzRS?*^+1(^Un%gD}pBOL&n$VJhXB*HKWG%-wg5e^3yN+Dnf zyVwR^s8h&Dab_Z!HV}OP%pW&I&WU<)VmWr%{8RJ3aV zXQ)jjA-Zr61FOC6u!1R5-u$tg9fLlz=kd``kJmSpp8V_>vjLFEHYZjGi)7c;b2jYsJC? zVya?Y=@)D`6?0SEY8q^%OS)I2w1YhE-d1%Yb?V|&>X+9#@g@(6(5!x65!%8o4cem1 z2fI=and;GLH?+coQ^*^m0p+2*tz*_M!A*^4YRTGD*_a|LYUBhiHX2zrxraKlfYhxj zRV*6>NNwy?xe=0eiB9<7y+Jfp!a>&MfOlPnSo%nUSc+nZvoPerhS+`@29UKO*O!{_ zqUw+;4Hu=D^=Xrz!9S!v5(pnhI3a;14j%!Jt^X|e;c5sFAdrJ>@w(?p%8e7+2pFdp zhC;fw+LkJZ<9K;|q&)|8%!fQs=qB_OI|I83%|*_a_*GPu$n;<=F6rvoE?@*1zFOkCu(56Mq7j~Jpq=ADT!}SIVQt)WFbMms?ayhAiD=yp~+ejbJ0f`;v%VP&MGjxqxjFc zYvSb5bZ==rbceH_8jhWgj#kf2a94=2YTu@@j6VApp=hEz!#cSms}!G7W3_}V?fCy3 zoa1QphIY78mlF-A?@ilh`}M7W_Fw)toVOYAZbx|`*2wnw-HFR(^c4EHC+|z!=la#nR%HU`b=hi};S6UVr{Q|nA7Mn0BR@P#=@|R4&!D7_ zz{lR$mCz57o)O4cXzbQa1E(A+VnB{}fT4SEfGFJ+Zr@lhX}b&71)9Ckr09pKe(G#~ zWUeDtUNWFn*P{-S$um18=GiK`h__Ml?pFZ<5aS11aOjj|HKa*v05c?t$BV?fSq?0b zBkX_=Ym?`27k?y04<(Tv=f>_k3JS4=c99w&Q&0L<*H%*PCS)y$>P^&Q8@r^ud_)3J ztRow9mWr2SR}`|k-{P}COALxn&m-A4Os@Wlc`asSatLBPfP5M&PMJ)O%6}<6LP81! z;Cf4nziNo&89?|3K$AYg5|~@KJ_jul8-IKTn1QnkraU85zAGE=l%UEOj1@+c<;)ps zoHElF+NM$h000mGNklwXVP9a9B>B zJ(;$nyuDJDZqz#`AExbdDu*HHuMg7p6aD&XL(g#X{w)7jwyThde_q??8GHuOqadC2zNe(Wp`?>=DaRcwagrwKb-Y7v-Q;nYcEvkfxqmCim&)nu5|1##8S&;o z0|##qI9%dU7Ug(UT`5;@b_=Bu{>TSh?Y-9G1(q*{?b!#%^nUN&KtD))EE;XLdkP#? zl_No$r*e5$uPSgz;f%uWVT|_r!X%Ny$Tk;u=KGK-wV5gqvGST-k*ly zCCmXJ4#L`l0%#Q08N}MGM`?U%Q&#E82)huYvY%i%9-+2de-~B|igQ>ukEzDcezprv z)wzO7a+iAX35qgB*)o#NUDTmtr{-u3ID{=4DC_`fD2iAmASHJxLUBqC7|c)?^x|eV z=;9C2DQN5Q02)Kfxy0=*p{sP4L@^!;Rf+|5*CERa>P8=yv@YNtj!FZ999x_v4(DJn zWP4%UfaJG{+$&{&>8fqS`(J**7FuzfaHaDTJ(Nz(Mq z$Hw$-b(^{R3o)Y{elp9|^_>AbC^`gdg_CD&+PtdDtfXWdEZ!;)!uzM zrib^KZHZr)ik^yb4L93zxC6%v^y*3*iL>kR1-Y8H8ei~1k5edkcgGjh_=uXn#0ehh ze?ZI?X=IzAXL}if=C;l81!=Yadcq1$(Br)~B+>iS@7-U;P0aBK*-p40^5obsIyth> zB0b62Z1}5fLY)1AQ6yb5;aK7ja*RC+5?(T)JI!?|GRrd=x2j!KIsIkwfxq&WV_B=F zZWk`x{ptY^ee#~7ZuMtTJV4>Dilx08P-4e{6CPf5{piIYq{muP2Qt@SlvS@uAP8H7|T4C7jIHq?c zzn8K+cKxlcLF4d9Jdu{WD4tL*p@fSvx&w;`5i5Ge{QXVc`euk8O;%w2<$irrO;4$b{&JT+ zg%v%8|3~8|#xz-ph}rZj9>likjOmz#Y}MW}v3a%VUe#{3*1OWf6~5GTLDYG+_%vdLkcwik*+i>I$Ek5vAa*T9Ky zQGiG`S)Rzw!X%z4CXXZYC9^K5Ob90sw-;%=J@sxTeg@KV>zxCs=(Y(~SmcLwo&smYf zW4d_YVt$0aAN8Gj1bxjP6}@vu^a!$SVm$?wuq6<^c8Bu0+>5>_dhm`)))hV!y?IBF zeO9hpM9%`~dO?Pt^>W}dHF{hiBj2g${o5e6J%L~JV1CZ42_cP+)n`i28MGg!;3*_I zjurGlx>*7Xh{`#2kFh@J8N?zKNM0E+ZdTb(49_r=v$`lK9%_>fvOXiQ_n4onTz(-e zC^{dJbdzs8X&nS195m`WDPGpJ5+5I^jfACTb{#*Lae3L5+;!zBfxI`lgc9w`+p$A- zWv}u0?W%m-=boF#D}9+>mi@@XW*_h&=&y9V4VlFs_Eiy`PhFjYGLECx)9z;`w_gA-$?E_ z;RCnVX6>L!pBM~0tp96*em4ZP<(kQ+Bw$NhMFX4*NAuu>&iE--)jpvlVnn51~IvnX*a2BVGKz) zBmUCk<62}IL04aQ(AD7x7ulCYe*IB3d+(#+r#GDM|e-P zS10&pahx=_rd}yLSOy#HqcEK_sICe-j<+>yeL4rE7{7<4sBD2v4P8! zb+&Oss#+LoUvMP^VmA)3EGQ{chu|z7m{o3r^O4L zo`W{`)}SE(EyAJ6T;3F`DwYTHF*kw#|D zqxa(axEOz1WD_u-L!J0wx>As>oL^V=l$1Q%(yVxwF{cl*oTZFg%P~d;SKe`uv<31a z57JR#a%t=9f2S=l1$jB+DWsXMVVCcfvo(ddoICnQ=H3WRlMF7WgW78qRupw)h7sF{ z`Znrl?NOD7g{PSL6;D;8POX*yyV0dTT9Ak1NF>;x6&;GkoOd8_(6sonkK7fZnn`B^3?OvRcF7qDZ%7JR`jC3l|gVr*#AysSia$<7%OQ8B+F{8Vn zh$;t{`?!e69GXHYKdJvI{}~jd*@RS;wYUJuN{%p*g`^vkO?b*5vavT}B~hDdVL$2Y zDEKoF58A|+>pMkg4YO0ZF#GUYKB;1{6!1-XTPnkxTbKT zqx?eHdFkpvX{}^hOk^uB?*knks~@1VH8%*_;$+8ZJ^`R7>ktGS!I0Q+XA43TCW3hi!?#KbLQR!^9(!=}tqH!SNmYv+pc>!RRC(i?P$jIbD0d5o zf>K+805JR3!l@{wDk=isfTsL8k4|z&O2tDkHKdx#IX0=6K!*Zjw%YP`+2m%fcmqY;QYCD3%pP=^J|=aZn7rp= zP)oTy``H4(&IIn3Wy)uWC5Tl4AQIZ^uZ_u!e$p;L)p9+39J~*45ud!l*w7Ti0JEmN zkp>i~NxV**cK>Is~;)lHVToi5d$4)EHI~|eb|Dn3Q@b} zB58rz3JeBd5qf~5!_4??gsBM>=%ETcR)wk{kW&TKwdjSbSfm|b(mqiA6EIgPH8^T0 zlzX5P0-d|y#2hs=Vwwa_(G^pVpz{!^fo38$z$Zhbd?0YElH5Brv4P6pdY{Y$z6;=} zR)a7notQ*6Ad0g{#h<7HSzE0E_t?V(5Ul~D%2jIc7(!4T<&|%LEiYsho&rVLOJa%Y zKjedD2_Whm^)-4crWtQv<(YChQl;wHFyv7nEt45@nztCgIl8j1hL~pOT%Im03LRhdAqOyfxPnkOz z)F^`BK9{p@$l}1wRE^aN%;fQ21J;RX>IVXBp)vZ#^Hv_5aP$Hver*Z{gqqJ#*hyaBPjB&*V7A&{k5q zT9j@TYGkQixM`A6<37~N<4E}!c{&5S2E&K?^@)g&vY?PCSsyBG-j))QZawx*$No0| zWG}u1ZcvQ;000mGNklf1E2YnG6H$ttK zD0(Ox_%MM_6Zqg|i4^7{b>Q4CdDVpk+VJE7L-!VQVupeiZzrczizhGfnerskM|_ar zG#CdkG-yzw(d-D)ERh6jZrm?}t1z_O@Bw*Clx=uJj<{AdpGN83tyoo+Tf~r+w zomL!ENi5AXX|pR=+5)J8EC!x?`Vf5{nu!FH%BqIhUS&-=xHknM_}_Kqr8-GMlL_JP zYO5PW*QxGn=a4IJlYkc)Qsrtdk@^bKzo0sQ<%J66YCU9L$ObW4DV+QYJbAw}IJQR3 zsrBkLPi}bYx`P>|+z`)oO^$IB>jLP~g+Z=T$ z9jD=$_5L-THBREOlLg61qD%pWTGN2P0%riTNwCcCpl|Z3T7`&-0fKPR3dQhj%>+X` zsjr|f#7s6>2s0W!n#eIuA;R*76%lsFdhFZ?`1+Ol7twRt=OsOO7Iol=sx%w#56ZK^ zo4Ux5m$>R80#sFW40M#vo}l4{esxRjFU;}zX#0t7b!+hIWuWQ6lh+B{u$ zW5o{`&a(o?nKZ&FahS?EherSScYFFi8lLM`H%Axacnzl?qHT^-2=z6bK2F1nW!GC( zL&MoqG`!UHm$jrqf7*P!#N|3_;EYVRIEBt4&^U#-7C2(Bd7Q!qPC1EaC}-F-FSg+A zsfgi8tEy1PZaCZV?5c8d0znIq%&HY`z;u>ZG=xv9nmxk+<-r6{%tB#4BxHamD|vR%2{TJmD=uc*kO#%5zD1B zj#CH@XH0)-6ipg$)vgk9E6+|I)bLZqkAg#FZ=#d^}QB!cxxZtEU{(ENqo6Ld7v>^LT`0HVbv zJ-sOQ_b6GHuIkll@*1Hs`(k>0KyZ|^S1CrgZfgEoM zRf(kSM$+G)aSeHp&hea}mw1E`uf-!=N$$+hVSDoaF+-uVSM`fI-qpzm$Mk;vnrZ{} zn1A!(k)7S%-O%u1^W@0letlOyo|cD5`9&HwA03~&)g5628kY2@bCu>!8|CaM1jl+A zW$8_MkmqL6C^VnT$~uo;DmZC)3G(UK2Q{1#!AWM+D6IMH98DjkCgm6s``!z~%0FA{ z79jCmEk}Z8`jFcifr%Z?QmtGRu3ER7foXXC2iY94&mwZ6LFUr6Nk`0~Tnxl)hn9X; z;!%>88bNLOk~@d8f>=2R5bc>5ztNIbLA5FDDVniUpQq z;|CmX71U3h)AMtGQ{AtI9Jh7!|BrH#y3ngf`T11-xX4wmW-8(^$1j95%3+NcSgy1! za+|vKJ@fGk*RPRHnIbyI^bd@+rut6XZjN7@`?y|)@i*6NwbS-TUO9o#Q5-=mL{oAQrftAW62=j6v4J7DO((|-bxW(!KQb-mo1>IvHC+2YbYuU`Af-zZQITi%#q1dFT7oA|q$>i0R<|Qot@&+4v$i z>&o(FIJi1^U9SF4%v5O{IsE~W&^!*m=GOygk zZiY$kfZE_|{2U@3WSIom)P}8;a}$j!YjDEu3YRui29!L+jOEl7b)2aSV_mKka#Iv- z*@ckRbyamF*_n{M(FiCn1Uk`f70Svt4D5;Ce!m9H1{wQVyOzOIN z4$A|`hNxA)4!D5A1Q}IZ;JghucfFFB7M*UYC!M1BApeNMM5lq59LSP@ea1RRrEksR z)PAN)dr#>Zpg7On#|6Zz%i;-UuBre z@B?F~z#44BYtT-u1=omB?g^I8x-QF;oG*OkxsIfe6&i>fb61dhm&aanlQBCm9>=+? z;By6_;A(9vbmqu;x$bh*yTD*9#bUO8GV(wWm=fBovq_Wf z?d;ac%hIeXk50i2(8;K$x}GlIerGAG#u}tDSWClboTqd$Iv|G4i^JKA#CdcvF|h}+ zP%F+v-+i2Hf<|+CrZRVox-~1+<0oI?vDzQ4sVn)kn|@Eto4Rd_<_*d6JV~#*0@smw zi2-JGp*AfrFWk&~4CzgV942t@=&tp_?W6550gL(ps%^(_qVb1CGg^F^yhns2__E9n zmwL~6NJ7Z)c^D(FI?m6gA?GjR0k41JnBY?0chX%dh#Kqz=xxXo_G;Kpa?4v&-9AdUoC^ z&8K5S&d$q+m*rlYMl_eXDU{?ytn(92XtUW|n0V!kYx(c&nn+aRDJ8=B8g`n+Mx0SX z@?U5PZ5fGXQru{(ZP5v*s6vPUQ{!^V+hA@6KD1?@R13|++=VwjllNj~On4Q}r#fn1yd}Mmubiw;K=Fu)BVA^FB(6vPH=8qY98g!sP?c zC68{)XE!GwAKkT_QVvQcedc(CgU%-NiA%{s^XnE9-Ec-;n_-N|y}R2xylcIGEBK;? z-K`NOtzz0j+HZTvvv1a1=0$?Oo66nhqgHLK4{kfW`oL=MifC?}hY$c@l`Xu-DzDAH z*s4J%5UU6Mh0*972Vwp8^pqK-hBmU=6AJW?ya>w=5#k;7nMXBNil9U1_)`Hv#i-yA z>>ma&6mVfnfqXezg8^?~u$31Zk`Q;d1E8v&CuI><0?gd!em56^t>$q$tqj%pvb>;b z^fLpkmbWvN2o7J@jfGb<>1HLRf;pR~E^eWdT~}HzE(o@ zCX(A>u~o&pp|Vvwf|ObaGonkgN{W=!{TmvR;qM=~W1byKXU_3l6vR{&4y1!^9e zVJaVklkJ3Iozix_N;n0W(83JmZOR1!2-R|?Ez2%w;hIPZb=y1THB4HPw1FoSWgRcG zii%|d6R?I5#10rS1VNDnnjOCU)5%8>KCF9Y%M$U1EfEp=41f4uTC(E8fGhHi^N!QR zTT0i*e*njaSJvg+)->tVA+`WdDCaX~4oC0ZILV&2#~R&A`vsrTBtLc2u?QJ!lXl*T zr65(3kkFXLQAh&#O8*89Pg!-qVYPS8|r;{2el0okZq5W8!EOZjN6Z z5D%d{d8L+x000mGNklhj{kmH6tcP*!N1a%S=@Ou}%tav29_o-15!OW*mt=!L>v zcq`B15*CHOOoKV#+Oo`2xD3Y|ms38aS^QWFaAvtd(>xPC5aPu=f;ELL)cB}ejh%AF zBiO*7fVcpbB80nXPS1~1QDLQ~nZn6g6}ST?Em5+>dBi!#%FM^-U0A8h*evGmE>=L3 zy}8Xji!|PmC&H68qV7%L(@tDg>I+Z~7ovFq zKAubS`QOgLzvx#r#s$3R;99_2RVw3RU*HDD8R2vd>16*(zusT*psTFV;X@u}12S*S z;wfV}xrBEmk3PfGXHKqvB&KAaB8Qii^@U~iVLrniG_DQltXCr5B9Ti(HV_*#?jPN| zKD@my{OsTv7Tdq`VW~ySj~?>LZ##r+t%PRP00vyzjt*{m-Rj_Ws3dfGHRmi;@yiM5 zcZdcVvAk4L^wWIrLN*(oAmhZC2&iSJezoelG@NaO7s%7i0W3xWW-hGgMR`{L8(N0h zd+q+(fn^4-acF)(CWZ8o6H+~TBgZ@6MU}&*7-rxrG}_Ii&}nDOVkHW=6h1+XE3`V= zB{YLyVV3#IpN9S^%#gBx7ek&vwtEB}*OC*Wf$#ljmXbQ1WIYLg%Vz!tGdD%IeVekerPU!5v^tW7aZ?b|7A> zS~V@l1v3p8gKrO-CIQfNtB?XT>tb(EkYj1f-aHpj#(~=NRKpL?6z3=iX1fD)^N=tJSMgXFZb(%weBM(H4P;OVe2N8J}9FfTD8;lJ8ZHa9b`8#3PT>b>5{l;`&b*uKf3~l&o#l)e6kmiR~hn%x6oPg@a!=E z?0D9-SFdEwusJzB8U2ObKIFL8dwKt_oOh!9$rsmIz2?s=V7i8Hl3rDbt>J^9!U1)y zq2uK^OcL)f@F~&*3+YB@oo?kSclIGn)t3^P0=$~!uP=aR2Pi&QRBK1cM<^1dP$Ensi%=RpZ&mknVh$vss#;{@b( zpnF1u^9*DG>x7;eGI!HVy9zMz*oF4cpbF{LiyfM3mS#Ga!Z{w&1V_NT2!G1G+44qt zj_sN@htp6?p?k!-h;lWSg>-#tQ;JzQ2CBx(BE$quML(~Wo2~_9o2HxaN7LJK#dPwo zmf{_XKAMYn$oi<|&oW*jUDLlrr?s}x&$msqK{`$8`gq^F)bAbc^9n*;H8^DI%Pg>X z4MBW_T&@y#kO!Ia)#mh>vujV}JV`@aqc6A6h>yvm_Y2ve#-&a`k29{9UcJu#HL`jO zbvnHW7NyXFRG2)er!Zd_@Q%{2}h&OSif=eqUHUeZhmCfkQOY2)|meW?^NZnd9V#HhG z4u-f*#R?wr<~U4!h{Gg!bDXN@JEjMnE5qi4lwOd%z~RdA2-AyQ-^9U!k#A}HV!ygl zu_vd0490=iXl;YYNUo{0eWt|aI;_D^8lI91<~JUE4)y@>5gL1hLd_gJqJUEkV|PSyzxA zj`Lle{9h#*9Ph$c)wcl=Nb!Xd(goD&p~R5K?}ULu^jB6ub+pJ`m!?s0o{0)+?l51j zwv6@jr{bT$2Y;TwgDa-5rw=(UZ`LDV2RP*%N%T(EU)JS8;{|fO zLjvb`D)D`bUIUY9un;eh@xD*=rh5c)L55S&r%<=&l3vP%%4cLg6g@6+cD2cs&^EMv zIv;gLr~6k{dk6be9Y!+nYy;@I#wAfLLNFUQk#8-hzCRf2W7u9VKZ7HX%!OW`zwURh zxbgq>{%!k5_t>k@!~wixv|sJID0DEIPhV9g+S2p<6$z4h%)4Wb?ydT@)tA+f=Wpqi zb*mGCWvkGgr?0ZC5r*^KP%G$=*>szfI$sU;jxPbwlAM@JkhNaD$@_xDFQC(mEo zo;^M~PP^5e*d2j}wkh!oKRni8x0mDgskAR~{?0x${@{3iWDVcq9gg|46AnQf%$ELI z1zth<7;s5af7- zudeWp^qf9E{_R#A*UIvb1brmKS=}l7xP=~$o7K_!RlH=A<#EaXv0QikZ62pEQl=OC^*y}N8DWpn+e;e`xm?=TnEZHaLV_EN6+Y(&Q9G}iWM`MXkqrX%)o zf&h@$;YDv2$hHL8U_jcmJ?7%HG5cL@$x3y>Obh4s5oYQ@TngBT1pVo_syRT@Gc?LW>3hSk8>4W_>8XgH z&k~9i?5rRn>pG%PcZ3rvv?L2|$wM=?q0- z?x7AzAYzK;DRLtfSu5Q~atApRk&JR_Gzy0f_f@Eh9WZQ1IYxbV zS>$AnUwCuXZAGq<$W@S1j9`Y%$42D+`i>Hd!-_aMJ3f-V^6NW!4YG1v_1gL#!OLW& z?Px4Y{K6`(;U>rJBU%;xB@Cu)qPpH0^_|rnZG)bQc(D2FO++3M) z)%>o1xxA*AUu|&;Go-Z5=~<7zIyf(f(d^E~aoX;6dk0q!S9^N^(MPC(2}>)HNLu_B zt?!9HxL6b(qG<2%&f)C&G@Pk2DUs~+kHl*S8CbPgH};qOr{BMH&+^(q40mZu%krxJ zXbD)k;+n#xdo`N{MO9aM3?9P%(Ov!CO_~nY)JiTT4#SH;k?J~}I13}i7?RaTXnGhM zVCMcR#i*bK2PBSm{d%>(9ySBve%~MEn1SkN#B%TYE6oWXyr*e&n-xzX2DDilcHK?c zvnXB^E_F%Q{DD$Q@7M8U6gCxd=)N(%!3!Dh)sEBacO&q8GD@uB@_HN|@yRP8o$`!& zH{!h-@iIUh=~>-`QXhmu@o0iw?n*jT>lDXce^f@O;O&nAc8Azsa-^)Va#b8)f2g@^ zkUEmI*L4RKb4rgX(U|HHwlvLN(lImy?rSB|X&AX`NS@Mq)sHeEpzSpie_N|+CYnn6 zh&;RaB9S(-A!f@g6S+9FFaU=MW^t6ThGHlC~eh@nEOPaM>2 z87Gvt)I#7@n;t#xv(9qK=2V5db1zc0n@;6z$Qimoh21!#p~bqE4?Lu^2>3(_L@EJbcvt{%2CDn(wet>kpMeI@c!RNLxu zUp#6;2@1fjzg#?O=-ewZtLU{`+H}=N0O<87dg`Xm^%vun^sLuJ?fAOGobLLOxV%ws z8V4l3yHaNGFqGHy^3{OX(yx^CY@+1@uIL2l7~pKRKDu=4?V}ZtkAzhdP|q(`Hz6mM z=+ovMwC=3lL^2m34{(Ofi<%R_&OUCtGc=Av9(L>>-MQMkDyAOA4i=?KX#dma-FLsL z-Z8qyZeGY+E63O@+gH}WyGyserMSl_bY2Ai-vHFTzFk=u?ZPhLv{iZ<&RSzuOKigY zovb62RxI&~EzEUsO_jxk7S}M3tMb~W9MSOjx#zc=HFTrza363#8yr4bYS-||R9XqA zUatV-AwjP>o4VQ+FL1*4xpV|sGvOGm{%kEsv+gbF+C&Ts1Gq&_*Cs~1bZYvkt^@}i9x>=Z$JZ;(S5w*N9DX)G%Q_`6t z&8N3*V}>>p4=p{_x;^o>F5uctNzBp?3wOze3uHR)D{6VJWx2?+_e6JAhFOK?`6BoB z+ZsO2@O(F&P30}TxZrhxZ^?RYa3`JvZT21cde+7Dj}`?#dM$Pv2j)=7k6=gMNomD3czLv@ zco7s-Yz=J&>h`|mQv{gp)HMG#t|?T=?Q<)v=hf|KUhZ8jD(I2&EQc^wH4Ys!~wM}m9u^o0?N z5bx8T*5sgivd*#)%ng@yEalEiDfH4XI!-xQr6DtzL;kww)7+hw++7QI znBz&qmUW^b000mGNkloiulcg^CmAV^al{#}O91j615;A)lYe zTgxd^lZxm{gA2#9)WPHH)qpt+1u zlg?mxVG4?iCW!bPi%EHN&2C0p5YsV@K0-R(JHmsTu7tRox-SIG`B!{|Sk?~a--9*} z&F02#2;M0f#);_bX*dB07GcY3m~_4Tu-dz6eQ=xR$DPflfwuFaP`9(`WlbTtEQz{Q z()~xME1Izj^DO>NnvLq3oWQBPMotk~mCJfE1UZTesxcqu!sK2ti}VA$E18!ja(T*; zH&U-C1BkrfFi-!F9_vyVK8`i#+CMs4?QKt9yfzGZN7Cprq~bE9aY-_6Z9G zhtU4TI*J^(XQ+Ci0CPZ$zi>jHZITD4z<1yUr;{Lht)|$u>G`Y-NpnW20D@>#(k`SE zk|GUF1DU|V&=oMQW7CzOU_;}nA11uzETQ?9pWyv|?N0`S#ty(txl|99mmNoI8bvuTG4)dnk3}$%HnMgcK@icWLE!tkfOJlROp7^Wo6MU8Y|b1<8M_ zvIG##z$t=m%hi@pGg7NP!B%`V^+H(lyv0=&tSLH8sF-lP;)9mj?RJV~U*Na^@)8lv z)$Fckifm8<^pRgpeT^*_nu-KEAFn@0jtS7<6VrOZSxRVv{f6-Rw_MtS5%3gdiTo;tI^xMt>RLdC-nt=t1LS zAlXVN-oDlPCQXx(MaYeHdFOiN(lsN$sY$=cn!=fTJyXTcuy^V1uG^dBmu6kh7)PrE z)JEj;lY0SuXrgcBIsW7a6HDPesFtOyQ-EzT@CO}o zF+e8)pkZko4^i+e>M{ zTL|sC!zb}P4~L5;&l7suyO#*0o!fIQf!gB3r0VMqBK;uLh1b!${#7O#gW%w)$m{{aGalUM6(-9&4(bmS(fUZ zVB*!&=P3HXn>2tDY_fC~?@kJx_2iT|AEw1OX$;WD0wklMRajFRoDa^{nxBoh2+yW2 z!Ao7w6gAHgFPEB%`)AbMo3bnDxCE@od&Bn*`*F%xga4=K{1XRZ2_oZLjLE_c78JbP zklUi!jsQ0L2!?mso;*#~HcE!tEGOF_B43u0?j7FQbt~tUwf1&Sz|vtZefcdkko~@b z*gyQPIzCXj(cC ziA#pct0e7dS6sttZ5$6}sdz(m*J_g=TD*B%8raZUu=9yzeZZ%kAzv}BVQe%a5EyTG zX>GzB#(^Hm#Dg0yFM=r0X3S|4f@mik5J+|Rmn;<4vOu#yAf^OPYmK=33jqV614fkx#wEI#7cgS4fT9;NFa<+V!umN_mMWpHgYUGGY#8rO zGtHEA*B$0%*z!fNQ1<^Mci8#7lWwYH_Kg%m9Vua#H@UlsDo+Ht6-P+TDzv4$wRmkO z6vVBGvMC>`Lg36hEv}S;?=BN)7NoXMs(KTH5pQ(!$s*nf%@zN{z0%r}pnM>j@~}xE zvn7R}k!dq)BB^fYMh}(e4x%>`c%d8AlN$D+$>c~Cc^?;VEeh*r=poe$-Gm;VE$t*p z8Bqd#&}_Uwmd{R)v__4CDCB$o^6h-Q!zH{WI5AM0pVtni;vGE0v=JX++N}5c{VO+h zT~~Grlf{u{Z=YC%z}lCjYDiD~6h?#?)G0JJ0Y&;@P8yA(!rT<<4<=0%^`2BWjMmhF zm_3|VoFJq+Bz*Mp?leKJ_uZaCZDUo7b#E$@q-+1Niz!^OD}jS;N>_>H$znk zoIlc2Y70_2dpr#<_WhMm-25+{JyGHjUWs_e=i%&08eSUx;gNXAL!po6lcDA+1Qaw!=Tce1KPxrOmPNm<$~6b4t*|{+9-!%l*76!*{B-Mo*G4~%55+Y<*2_>dZ90J zKF~u&FFfDpc(fHgszIYpC=TnIn2|svl23 zl!g~PUCpE6^uwmt$_uvDa;UWte&!pU$^PPeKSO-s;L4TJA`GI&w-1-L;(LmTgHx7Q zr75SnEs>sg0ujQbd*&jX9Y4hkEXhsZxBN(DZ;Ca){iC~0K~VFzy~2Bh$urHQQ}}iw zdCv6|hGz(Q3NVaQ!ciT~Uy75WiHOjCj8$n#4Fu_yC>~S^5YUk;X3!Xi$gv3PRfC#;DG{qyW9A&^Y0T5# znxhQl{mx|vbdKBiIOG+U_mrFJ%u_WCn-6FIVae~_=M1OB9bDg4;wdt-bNaA(BEw(ZS;wgw5#)G; z-Rkx>e&Ochxh<@36DO$DMjD2vb9#wK7<^HQMqvltXmpY|S-G91G(4BbsO zbj(HRSY1@BQAb|66KkrdRla^l2+nf=?H}R`#NF%g3bVN1IDDcx#t>AV38RzF?fARt zpQFj2Yu?Y?+X_1;`=|6CX{buF`-2Pcq^*%T2uoR{yG*@PlP{!A`y4aP(?ZD+&@&wgZ7e6B8}BS91D!(-{y*~HLbM1uqa1!ZON0K_ zI?mrHw?CTcS=~{&Y*fzE>Q%BqnCTg_Z$6Ua6t3=S;{T0$ZN$rZ4MV!d|GQV?4l?|0 z&{I*5Owu`jlJtJ>9_E8q{#Nzo-6amLHI}XF*}KI4Pkbuz*>>~cu@csJK)mjYhSeJP zE?rvf-&&lbC1#MX0_5hy!wD`fsqfHup3vI8n2br62pTky31#^vnwwx2Vfob@Y6sd* zf`R0{0O>pt;}_oGGs8h>?yFkRo4D8UI^p~}lDYd>Psq`*XWo*}VHiCE-xMKpqziT< zT2`>M!YS6^-0F>LRf#~n6F!ymWOA0#E*x@GE0(2lq9|0^Fy9sKm#>OxSSEjY9vYbL z)!zPU|1fPgr`KK_PLFer(MOm@%-6Fcb9*(sCCq8b zP;q{QGn#{9fh14O@NX^Qz?00%>qKLYabH~Y5b4Qc>!!k#gjIn@Px~d1K2vpo;#0&r zdO~Rg{eJ0kLrCWb-p0>J-LUJVeWywDtfrVl!;3v741jb^ZS>+BwN1A>FWpKL@2q26 z!)s}(H{u90xT2@!UabVF&y}0M0%C&m@AaJYIkI90t{c39;MQl_{fuTZ$aJcRw>Ew5 zc_;h{Jr}-qFzW(Y&Nr&+dj)jnsx(MxC%&dO_6Ya6%fz&O-lzqH`fb$`Y%B!}Jej7? z(oEr*+c{O#YMwepU}B3^2U~nEZnYpcL|3BNF4XqkZatApkRfEVFQ!ZWZk$VD`CrkT zaCKSNr>0Ow%5ylg zn4a}W3i_o`i=uZ>;jr|{*5>}bqoI}qHCJ3^{gqVTfi$d+R1X%tW|fa2U1C5QJ$Dri zxB-p`lYM*Ruzxd}(WCMm9faMeL-*I~OE;I@B(j5~Fd(t>&n!ZnASPc^SPnTBJ)$-r z51hzS$XtoS)i7}^94a11(w7uknRz9dH9Eg6Uz1o<*0hKgEHOGqCM*k;Nlvi(8rtoMxZ(HMWKwe^K*)2c5gq zFOa7#%dCfT2eg^23uULOyf`-kojM=q9g)>qmdLB~3IEhwOL(bW*HzI>Q?C_WfHM@U zYXQzVl+Xd4_)h;(O z(6%mD-P~DrN*GKmA;@LPM6e~;Wmp!{09$;>%RI|eT1RXV=K0Wp#E-~}tpJB^`eR-m^pK{1l|dax<^75D2~JQ8MKQk^W(OOT~L zm919&!QtNFCE;L>Q0>LinO`U-L9yYIP4aTV<@^#M*UUkECSO_DT7*z_P5+s;*93Ux z(4ZVL;81-S*pjv{b*sa`_$4M)RkG`uCf#vCK_4ao2^ z74?|p>=QZni>DNF?89NWzS>{GLBTp)A2vhEn;Ay()(B-y2}fHLvbw++tjj)oHGHRK z6HvH1d!KmGW@*?>Nec%xdfWwQlPf%9Rks9l#%04Fx&Upo+tAdug#Zg$$?2RLH|Djk zc^gu0fZ8_yQu@Lmjo%V2@a=}M&tE~G)GZ2KcEvM}b+FOiYmK4#`Ncf_H+o@U|5B}< zd)Gq0d0kIXP)eYN*jidx!5|E$3@@4Yi-i_m}rWtp#f(oA%)HK zl;=^M_I=lnUctTn_1+<_)~Fsi-dUXWxD0)Y`B>b5>>Wh$AcE4B3^K{W)Y_-W=`sUM z;FTl)>WcERZZ|K2)M%K#m$zKyv9Okn#t&BqceH$Hc{S0K{!DE;E_^$&09W3WC;gy$ z_KC~cd(N{xeU^sTV!wh#X<1KHky4-3_v|nS-gQB;7gAiuLOMH>0o1*xAIp1P22|`q z5`Q6?NRVcdt$t*`U4o(5h(oaplX4WrN2qhhvH-TsDMK2v^ZM)sUXREgm41KS3u{Lt zXXES9eVoSeN-A5KWZi;P#oncR!%iL7zE$1%)y**!CT0+Nt zf0R>$`nSPCs9t&IJw7G)_-H;~x-*28!8uwkN>Wp*@{Hjbrq2x~rJ`OTH_cX8LU|77 z$x+j5-~#v3L=$X|KUS4`SJ9hyxIVZYF|-pi+$a8B9g?ZQ++~#0g$aO}_IVBjxNzk% zBsx2J`a0=Yf&ya^giB0WGJ`*KGYan?z^FPb#kn)L*3QIQiau?qK&0??K-jLsMJ@R+o>H z?)rZ8Wc7R9YOiP&U6?qE&Xmf>;`}4F2#~;51frOXV`rL4Et3A#nGQXH}n;k7`1B zoUBF&mlJIsS;F=q1dkmR`y?l%9IsFe6;hjq!yR>Hgwf@*?|5ZinUWsHYLgvIs1dhO zH_DWyiv4gtGO}W>DeI1v3sAO@8X7aj#2Oi5iv!OCd?5lZSXq8;H!qdIJ4;{8 z_IQ8YuP^nh%X42%(i$k zVVClzY3D8wu%Ty>v>hy_i{)KJ*fFRK!s0^DqWlsnxk-m$9zoqeiJu z?N-`cd#a2MSKjtx<0gpd6s;Yb6!t5reF56qY%2z;vyhvL7+vg#s_a6kB)08@5xk04 zO~!&Ki9?dx0ot-f#>G_Tb8?EQQf7o{NMn_g>{BKz+dCxmwKSGY9&t4k@<>)Ma$#|F ztaj_myziux(bgLJXaXP(_l%F;d?xpBU3BD%;R&59bCQ9a9foz-j)Ngen%1?1(B#?g znd#fJ=li^PqSHoHnxl@zy>3GZX@UMpBF)gSoU6{f1eO+F#Vpv!!XQyG;;S(aqiiKg zmF0FmN#Xmm7rOostFQ2e>Gz!VMQ{Vx1)JWDTzR$m*^3l+O13%9xu^6Ztq$-Nn6thi zUVJLASAdXZ(|}Uiyr5o_N!AOX9vfs|V(`*@7hAmA8k%aQ^I8|WiH9&^4)EC9~1{6qk!^jr|5Vw-R)Erb_ zAb(8?O}}-EFupPZQ$1@8J|&$WH)q$MDj5nrQSJ3_r+D?~zZCbeU<&9b+-XmJ7>E&^aN=jQaeAupSl%`=-qRf%|m z_xjS_{w?QyTPIUB6G-Pa&~qW5pYwtrUL1aPZO@)d+hYn)=%RNw)BE+&IP9L`Gt9Lt zc-h3h;@A0fj26bQy$&~awP45LN56L!KB?;h`trYM_!qbe3B%dgwU6~TPJBE|tMTGA z72x`4y?1j3_mwZ-1tFULj`Zd$3$g9BN#2VSnxS>;!@d1m>tuD<(7B&abDfKwE}XcW z;e5g(uV$qzjn0u_^AgO%ZFx>KZ+VZi9D4`1s|^#U=OI{8ARa^_u`{K;RSRpu&B0XW zu{nU6dg;`R^LRAnBVIwM&uj+Tp>{JCp$E9JyPKZbBs_WP!`V|PA*qx071G`mq7}x* z7J&e)4{qJRd>_oz%Z`h7rqS|OQb(hSa+4%tttx2xjCFdmEfdUxxPR%s_0b)Q1sAj# z0_wD|Vd=WLoSkdGoRvegm^o;%WP6ngY*Ibo}8sFgiPa zI>1LE$tmIJHsq8z<>;J6Tp!%FT3-#khN>1^ug`VID%%CsJD#6{_J=0sMRldki)W|L zPG8l{$A|eCT;UG47}=}6TgE9EIEQj?1UG)+4W{2np85VV_dLJK5J#P~J$-(mrv&!5 z=`)`DJQuUNG)wu*7#YGGc#LG6n}>2y@T8Obv{M~ z(25+Iw^>UqmJsAc8f#1h_w&hziDda88A;6p=~*TM5~|O0bEF|6&39c#SbmMOh)A-gMrQZW4Z6Jr;s0GYnsIg zyVXim*ZWsqKN`6^KdH?fG#X~}#~6p5q?l>pkq&45g{Vd7Dw7dWSNW+!(chZu6eS-z z#_iclXD3f7{7y{U96yJn*r0tu42Lx2jRCQh2096>8LIqLH z=jbEM&tr=^dGSL4A)KMxsYY4&>phKXzDc%&%dcCBHFe4bJA%(C=?)p4QxBg3!dEt9RjdKp*sY4I0co~BkjV@q?@Vn~m>frYMORvty{_OaoHkV4;j>UzR%j`nX zGcBUO5Nf*+U}=dVRw=q{(SMRbqv7o2>EZ180!l+(gLnrug}1qMGNVqqb`jN&XvR>5 zH~(3!ZyHAfT|O%>nmgd}L&~-Aj5SMPQrpeji(IpUxL5Gr$U_5q?fXbcIviHkTt zz4nnDn2&V-GQT1)D|s?49$T7Cd0QP-!-UZZy_c=26)av0bxP;Tmg*{=LVM25vDuEW z@YOVd_;0)JFn5rnyYrUhe!URo%;b9(&r}<5CHP??L9H-_YVkkg+TQJ9tXzL~;&@^*9l^ycKF<6xuMoP(?v#Ik)QtRPf3 zhpjRUJza8Ig-|OKijQf1>F#yjlDsZl3Orw0cME|N7rSatp`@Lw2r*!8CEwgefSi)2 z55`WiI_mm^><6k0BQ&7F{@LX()QppD$kl#iP%F)i06FP6V!}A545FOI`b(QMoQ#f& zvb(mt755=js}d0+K#V(JoDy`5=vdgN$OO6zEPD+vcdJ9}4!W`KJ3giv!tgTs%H}J} z-Znb7eIGb83ukQKDQ#&u9flJdSclwL;lyP)i`TBU)UPg$d>GY886NfLgPKTa49}WA zyVYZQzktORi@W%o;bi0~jj|)9vosth-qeXNn=>@+F0d0Ku8#WEe%I~ga8X@%{kaJ?m-Tai~m`<1OF*(BPeQbU%lgpme=af(@sMH?W^?b}vk{F&@0IiltGA z9d)Y%6=L1_A6yXbE17NjNhq+W!jW~y+wHaKbawrz+jj2P&$DNCgmObtGL;sLB)xO& z)&3}RZ~RvsuT0gmtQHKWn?5O3Q_~RU9fXt`R!|5uVoy(slFE~L*tFINPeQ@IztaMzpIA9q*ebY0`ix#MCBnKEdG z(Re;X|Nrfo=J-&a=a-vZXob*u_Y1ZM>7vWmIj=9AU-8O&3@lj8eSv4Hp$iV&6<%j? zEg;Olr3>(J9vbM|Q05(ZtzcotYkC>APxC2G1XV0+`IkbbrUbN5R~5M0!>P{{J-9Ua zmdl+&fjOPR))4Rrf)npx@zIqdNm9~MggqtYIV}5=wTloAJ0^Pt)yl>4RpW8=HG=XU zm8I>PKdMuxt(P>vvzX|{V;1rcEIXa;IaK55DZCQ8k6srVDx=<8T9%xJAsM0m9r9oe zP3MUH4~G2qE82ye3-uGW3*Ar?Q=w}nf5SfnH4S-DU^Ay&&o39_(K3_37gwNq1bJ67 z^Itz?R`J2-1#3t!y^Jom84FcxR8Cc<%pz}L?#+BX!gBuEYl<#HI~N2lr_=mfyeL3< zip^67C)GBo%822xk~=yZ1CFH81sTuv>JkZV?7AQUbpCr>em-A8#pq7{ahVuW=2gu+Wgg zqCojqy-HRJcm7Rc*(f*VIXNn4w6UVuT%xtsx(O%GG)*_iJL(b6YoGIP6ud>KSCoPyS)eX>8fMW6}X}3gO%EEQ(EpdcZ_$$EwiN@ayXUp5w=C&O_Ki>SBl-V-NY06Gt}Vybd$qeW(^E(lp8yGv ztnQxGDls*}qFE#ef*^<&kl&d*u@?PE%jSO3ivAH56hy2&?33CD<_!`*qx znV&KQ_GayPrZ9EIiV+sKA{gFlSOsJ~9e2jCA?eB6F} zw&d3?Y0lt}A!E)7E=YJNxXK6rGHT|WVaU(B$zfMmTm{T7X80eWY+B7>K4O*t3JIh| zh4(!amx6LR)w+Pi8Y&hlUoi<@i~|2XauN^~^}?cnS<@(^(d;mSJw{cUg9%GAC620~ zbqa$GjhCf1eo{BL6-L9vM0Gq%CjUxXleS;j*}&w9wzNem=7)(sKX(W+Lg3CF_Tq(*G&4CsklZ%R z{O6N`zD9A%(EYJfhTvU~Ofz0Wh|oXz;Xjc~Y)d+d3s~JiGP$Q9ktcKP9x^?^e|R$fdX=0<}R zBTOSmY$^ybQ-42XHX_GN@(GvIx)~`1tRi-GY8T*srH4iI&Z2|4HqXo%6O(5p};1K)QzB8Jbub5bvW2Wr~I-!G9p*8Ss3f@U^m!jrqNXMV~{yA7B3@7l%`n zEXrM}_G20K@#@Yv8rfX_9EVe=iK_+In-Xd{8Z|x{GLF$`rhLQM%~z4ViMzL@-k=7N zLdIcBuiJLwv~+UClk3tzUkLPXdB0b1urH@2g45HPb-TT;innl5@&)W|l}1QB7HBdk z=d~}&88180>|H3ZHF28rK;ca^A&As0)*@o84i+R~-iUvvq*$l8e?mo*7+y0-F`5`I zVT#1%ZwCW=Uo_PnIN5|Gk)CW#>`!UWG`Dr>J+C?P(!5*8kA8^P8RLU-cx#t8Jp(|F~0i{AuijI zHH=rMbs-O5TlQ_mXOlyjKpE57!Bhs=W6`Thi>==H#BqF-!RM#GWWTZAou3pz>C0g+ zA!uSRMj5+x>H>le7CO__mVzA=td(qHb%)fBQ&y_Sw|ad9I^c{`q4FncJAcF{c7@~6 z#Ut!KVSOgV%a?C&6`aB$ZGv$a`P1T7emU%y$ZxlMGVIFAE;Dqfk~WUZ*79&N{gL?+ z@#H_Kfi<3rpLH?{h;7rtp7c7V#_EK1YIL&8u*$!x9lU^9Q!6mr@^s~gcqC(7sA{XI zH|N^9JTdvNINQMW|5G@_m-5!)bC?)25I^CE#P*a$T}n6-8;co;w*nyEIH;Z@jAIeT zlR@tw+tdtn5VEXlB)1KXcctFT6LEm3Dp5qEN`PxtT3YE@}+$22ox{ zSp^-6?2_Ei^&!Jto;iF_KNEi-Z-DentxN z^MerY?RdGIAMIX_O(vaJmj7`l10~Lvz`A^v2-gON^DS4kO)$iALaJ#tik{FM*I#~{ zTnz>vyrwT^!xL)ZP{~Hv&ZkivH9=5hl1Wxt-}TG z5~j6qTN-muxR}mN)abArB4RjbxaHidHwojeIpv`iJ4lgoiplX{E0a#f<+;Gv152K~ zfObV-oNNA(VyL_p_O?H&TcP}P%Up0sUONbie2DH~%A6(jxyTGB1u30AL>9B;FC$Ft zavoWCs1{Kr&+@D4HUIz+07*naREjJss>!w}BB)hkM8@dk1+^V11Q-Ywg7h+~ryzUH6{eTS`i|I@-$xFS`6m z+~!-J5NlK&BWyZSf`3pP9b$NqU)CN=gN1uhx5P`_e}$S+rPesllt7OTAAf_6o{UXN zDr!-axA;$prPci8oapTFU>;>=vM&gcR3Pne=hB56Fh{}ffBTZ$M~<(;Az<-8Ox>NO zic>z%2mGWUW;e7n;-pPHlKDPs?%hy9sq}RsYgj{L{7j*pbia`Iqy3r>=miUTCRB=r z6%UxpCQ_n#PCUf_{5xqPw~0b(F-7#`YY6j4Q&4gk8wieOGFgdQ&18WH_MjF+U>h`< z@Qh;pmyWe)mlQhvgw0DMbUFpx*wlN&rxUCSxBI?{npz1B!)nL}#VTOnBT+rRjisGs z1fGM;JULaR0mc=La>?rAId~Q;vS`^5i@zI4$}NV>hYgWJ3va;_c9(f5$>B3;`c~^? z6Z-B;G4%0HCuONq5>-D7`dV9~)at8J(0!_1g4pA5TrLK+a(oRJ$DkAYND0W2pnU;P zk_*4mO9AESz(AN7LE_b%JIM2A#!bu*6M((cCRa(EXU3>#3*;5ScdbQI&b)V}AiARu zop~{!VLno*<#VD44J*#7v|GZ8etLULL9-%->`Y$hqr>xOZzhmWWVrHEXvEhIj|=;y zG3^oOt3Dt7Rq?(bv z`Mf}_YJ+uaIweiaI)J_E4Kh$IRB-AxtKHpsun4CWRu>(}0&5uHQ54M#u)w9jb6BVmGVPi|wvqeyTX|TS80yaZRZ!7(yd1o z8BT$!CgPqj=DL(|$^-&V?3r9YBKalQIVZKXX3vn~vH>P%lJXhBS-r|3Qrcq1)Rn?H zW?R)czmjU0ollzbb%(Dpc4>~xU43v)p}Mez!AJ!WItdw4&nGaBTcJT5wV%m7^)yiy z#EMa5P57K*dpALpudVT=kZm{f(}YDkSTTUIajZU(QP%(u?mxrP$PY`bCL@A!yhxESTJjxF zn+l(wu>3LFzM(Wa6fL+!&)d^s)pL~$ za5C}}Uf87#o946WyE2;VH$NbIGKIa>Ah+8LCt0mh6AK4Rwlc3Vh&0=z>Wb-|f0$n` z6Qtrx7oRGQ2o)2A`DEaBiUXd`2tpOfb6FIt=N5=FNktXjN)juvR+KLDU!;nTTJznY zrcg<714brYihiIKDdfrP(bSwssiaah;h2ufh`sPhM&P)nYa65De4)=tWu}zRqJ~q1 z$v2B`p+8edY;E)idzu)18DyNVDbh@=@b4f8LUQ%xyU*3ua#gD^VE}b`1bZOB@UshL z8hDDimC!J5R^c8w1j#3~XfnOZn$Weo6m@Unx?$H%ZK-&-M}_(`wXULipyCz8p)Q&t zQJv{c5xajy@_vu7;hL1=KI{4bW#p31<;2o+mS4{ovgV6o9~$#lfrWR01KWDg=&mNkA&Sk0)REz(n*0z*-ary5XwG{56cLmZwLVffv{y=ixr44ic(OQk z!HEBbO8i-7q%t~)GJod!lNcdGH%RE-oMM^ovhb0B7;a1eD&?olbr{hKgq`)ENu|;K zU?EhKpkxbmM+DbD93)u83yFOnIi*Qf##|k90oEb8bt|HV6yH|uGmz1n>K0iOD4S*_ zbKJ_y1Z4-=kB-dEiX)au42z&N=3x1x;7*2ivj&I#3L6t*i!vX_A!=GqTR>a8W0fc9 z@G#5|8U|$?e@F1lP5+UA_GRHkdQET{ItH}Haa0GGt2El!rnI#dMYxHFu~w*2SRJa1 zq>VH^Bfcb6c6fB4NY)~Qg%LShc*S_>O;Xjo%M7;w%_an?xx@pd)K6kmBLGhzl@z! zpSS}T@-Zkkq9OV5@vVQTOpMU6Mwtd^4mVU5E|j~=Gn&~nhHrqEavd=0Cza#Q^~yR5 zLQ-{~H=WQxm`_htUA4ATJ&vmS8`A=LnHuMb`qCdBosqgH5~~Lup|7R?N*7P64hf)SAeVztqV|%pPP>I?*_?2pr@x8A5#4 zm{6Q++=)q;Iu&SY^dUKOoQGJEsE2E!fI@hvdL2VO-b-XBNU~z^oMgABbG{;94F+X1{CWN3vBA=uf zB0sq;$A9VRM~_oXDJn&gMPdz1p*g)oBLkdi#z-O0ukoCG_fC4~L@9wrt>V;Ran zTF8*m3?+!=cpQ0kL|z%SMo}Zqsii$&C}Vg!R{%yK?Glet5vPwGnYT3qC5m;kHI8gW z!myfn2C=y7&2hf+o0v#eT#&B?Z9GfFjPNye)sREKBsJkCYDNp4+i@ikYq;| zNylB{xY?tI7gr|i(c-RcA&+J~J(G;<{!)5<56BpsJi|wlR7E_$eUSK7s60M|`Y~sU zIMwvk9u+?k#P?Bz-j1CNpynlvWT;i7!tsMI|GTkBkg=#3^yjB}emfsuA0K}@-v1DZ zbZDYd3@XQ~xTO5tUw^zmd^8>Irs)RP50h{g2kq)HGejceFG?7zRX!h0?CfCOITpDr zMY!nUJkJmG_~v+g_4M$QDgWXOVq-u=4i+ zX?~cGZ=Ti(__MJ}cH^2S!u|fk-PMbA>g^88T-?&>x?j%$7w|-@roCM>?Rfmz%@KAh z@yz&s|8jTra+89v%c%`xi5kDm8ni5Y4^FjSJuOpDRJ+tTn&R6X7Gu8j^ln&dCBMsr}!LIt&oh;MVBkoVh@z$lZ&*w`goa} zi`#&Bjnh8MR?$QDJG+3Q5Q|m9DH3Lxi#?y``*|@|k3S#pzc(hvKog|as%?%)&MDO0 znqkP>TGK@H|y$pzqt*&H9)G3Sd3U4w$Xo8ECz5C*D{RK>i$!>~B9i+6O zW7#Z+=y6#B+){XClp|Ckj^wLVnPjw)<$HDe<^KA!MT)T()_Vk5Eh`*I)sMb}2ZzAT zp2eGb`;UiTza@CeoFL>Gpd70RRmUfiJ3hC+y8Y^K`-P7bLeu^>47ydrvAnGEzy8PL z+kbXxqSA`bX~hcZiW(du*W3|f9tr{#5P`Va`^k> zdT+EfVejh7|KWO(!&f1e?5E98^{q@?hMG;OPk*`Dc)0)e@y!qARBP?lclYlzbdvmH z4a59S(-Pjf|NG)Sm-%F`q*!N}n_AzWC!SK4+BFPQ? za&vNZ^CdHJC)4z)csxvnIt6FM{?Ft6cQD%GCi;tNeY&XD*qsLS){gF3Mr=}sR^1WT zNIWFreW2qKPI`R!AwPRI}|S^?`12Dv-{@7H~X7U z>At*8ooLqLud>?7as3_ zdHc&>%vE}8{$ib8E(_1chT*pGiwJEKt&^KOH!V~9{?$bkGIZg3HE|!soWd}vu3d}T zg_v4X&xma%dh_Dn*PfQPH#)Q%Z1u*8GR7XL-tp#186tnnM*RH}PD&4gAb*0!x9NWB zulN)j!Ty}c&4+*9U!|pR9_S94m&)tZ)T_kSpjcZHciznzHxS%?tc!_M=uF1g!Z<1{@zz8bK}{aESEi?7y0ghO26c1RTkY~mMy zyWRD)+dV!0lKDV`zmQKQ#Ejp({AxYvHOfD+Uc(uX3i-G7_<7)nHm+r+{VMR|!_Vs~ zkM6nJH3mik*Px&53|||#ez62)KY`AC3@o6yo}gx&FOi%P+aDkNBXorw8~>X`v*ZZ)C7U2%g%<{e%@uzG(;TkoP5l`2D$3{`hdu5bl+yE~$X`SHzKhziE}o0CZ6C+~)?r@rpK7UC&!O*YSE zA=Q>h{uF82-;sN)584SiHRW$T-F5dB4Dr*Vv+)nY6EoqKl*44}d(GiFt>>T9{?^UD zH!>eII2$lY@(WHG8$8g-%o7vQ92Z3uKjBYlXxiPZGj5Sjh*Gp+oFf%HTz!1Co@r_G zOl@X1{GF~I7yLV=7pp?f$EUCyP&EhDpHxF<;=VY$3w66Er&8@*PuCYLj8-p=b=X8+ z&k+t8v9pt4VI=z4nh^$!?@95pqDZHLyThG(vio&eSFERTZbm~DMgVfj!|i8Rx8DR^ zRn~WpyYf`M&$7}D8HSr}A*K-=*RG+GJFis7UkgTS=Gw`fBmAb$cZXFs45xN1B6TM# zO7vS<{u1Or-hWU0jp2&(;XNJoJ&3qIYy9$$nR4w#XVR25b~6d&1!P4p%2bY%G-oV`amO9Xvk#$ezo#c!;g%?Yxw+Tc0dhUrq{9?uJch;^`KLZ62fbPQ+LS z4G0x(z^=U|ko=^pRFu5F{c;V}uX0Fy(k3|Jband$&Xk5q9qF@iybv7=CpG{2=F25K zzi65;U0ZOcXgn;vnVy24v$MYvCxK@;uiIRA zUjj9rV%DE%_UUXq{gSE59y4NU{ov-fmu!4x?`EoFUiWx7(I%WS7dj+89~h?qY&|6o zukXHodiWtccq>*?Plr-8hEiLC&SJS1>3tJ+(s;YPWU7l*zU(rI>ee1~F$8L&YK-jh zqO#)ki$5J7zF(D!2ZrW)A|+ThxNJyXzxbm%IiDj^sP$T(}v>c&)ktS#~v0#(Y?uvp;I^jASX*SlFv>9UTA>$z**XNq&@Ybuf{2Zb2Bn8M> z3b@=r@An@v0prP*z6Y^{nTu2LB-T|B^V51uL@k{`QzfqN{;)fIz)6bBBdRo#bY-Da z|I`LZ;uv%zzow>OMGAD0B+~Kmmy@|#Ji^`ngPN^Ix6i=wLUcxP_l~z^#{J4JZi9#0 zKP>Xu&I6sv|8JT|>Ko+4Hsyi`#e(NQI{$RKLM~H#QOK9@OsQl3$*53c{h2A@Ja&&weHq`2+_ zV`9;hFZYKJp)ro6diZ5le$DloL@VW(hD(N$SZrk65-}vN*WYzH4t7XOFM{PH~zI!w`j9XWYmqr$oAdu)JaT?Go8YC+)94bx$H^ zEkp6tK(G4Y;|pqAndqF$RKWe!r}DKDkjKolhj|BP5maumZDTRfJkzQ}v?^y&AhAYZ zrrGH`@qX=ZKYw`hpCA))Lze9xRh_5sTy2?{kA>PLUdn^)5|Gki5fGX4K2T@7r@kuv zBH5R)WILZ{>DT=t7VkiBFy(>TA-}wZ$eT|FqfQ zt+y^8(S+ymd?~p#=;KD1h*Oj7$y}qG+W9#Q>NJ#j)28-2NL(^oR9tgtz}lxqq9M{9?OwX*(PoT=r$=$PK=+fpJ)-@VjRyysH(+du6K3{sVI z4#Wa3P9Y$BE+#*T0}^-69($-~fAwL)*&pT&iwujuR4i8%0?+wW()Vt-zXa?muT|m% z=>e!xAnMuh**!7uU}(kHrDX{ZC;8pM8j9h<&)`{Q(T#7+N6Ng3*_1eNRA zp3@^bPa5aKRX#YozHft0KIgQ4zT8H}WfLUE8yOl!mDUw_2TuDJPRC{jp8%g0hz>G* zcKGaZr^$_Zm+;Fb|&5b9hT*dQPJ^>$v)P$)dRd@yYI>^ntGgeD{ z#QTElR&9_v7unu)IYg9K|0oFZG_!-@|Iqm31)%pw(%C5d zAMYTc)FsfosgK5nNlsIDGu9}-kN#uS_6tKQ;IzAAJ}>1C^Z830^+syB)w&BlZ6X&ZuE-Io)`u|&dLm}=q(>Z7}5TZz zP;yk@7%hC>?uT~>slqdycDGdK4^dt!LjE*R_q-!A`KMzEf+*#DQQ1&lU3FBPK_oq2 zg;e1!IPI?13F9W9n3@0pT5vz+yNdHLS7$;zn?iyJEOgMtr`Irb0 zc4@UbaN1wbLK{gHN{ufg1@c@-g_E0`)njmcUY9tlKZJK}5i(nJN1#T<^xd8mX$vdn zGum4yV|zZea8mL&BwuG{cqbCNtZ2=TQj*2Tx&+Mp{2wr!SO-LPrdY~Vn+MK}CHets z#dYB?3Aj2%U`H8kxZY{lSNEFBLRzLvqe^4qZZa3@I5k7i{?%19O*d?sp*Hbo;FxVm zKi*Ra5UBYd>O(JS;QOv2$Uz<{AJQ~kRj0VD>f#KF`wV>SwmhHF!t?zczt1D()Zn{? zt~<27u9E|BITU#6bsY69lXOb2f;k^pendA6ua(ZD(zdPBwSMl+GVU>dtw=pv)=F3z zg$A72DAIWlG#va{z&!2|->nMs#BPd${fVZ+v{CC+CD_j4JlqSg@%JE|s<3W3m`~`J z)8uZ+_WZ5!#053&2D7WB%_(3m<7^QbC0miUQ?1_+R}~L(C|kHDllF+T4M?m(c$N?g$jzhPj_5EIj1-P8>d6gjvyuhDEX+AiMZ35s3b|4 zB4Vc|!f*@@YzP(JPW6CENbS!5#>u?_AUTDsFP4VK{P0%!(^D<&-x8*7L&=rvrm!=V zG8#52s_0oT%wO0t%=VvsPLjyH!-QSW)6qK3{dTj+1?0) zDTfT7goNQy>Q*qbU>hp%V%ZnHkT@9-{5Ouv{>DK{k;1}3A?bBq4(c=ue;gRjT!F$V z@thM@I5E7D_=25vIafn2FP??PWjP?IiMwt*={u{1HFxefVY)!9c4mYsrjqidV+3Tt zx?5O5sPK~@H1ef~SIeD|_L~3z1uaQLK~w=4KSBg&rV*9pdIG)9v^zhT=%iW$DctrT zGq?!AZ&1ZkZ!SL%e=&XEXS{E6w0!bk4jhZ1u=|PZc*x z2-_F99jk2`YrSKoPlGrNq2htT@LGN_q1|}9<6Ll{mFo#v(^UbZx%q-(VDp+B>%fQYdB~fP;Lo0 z>qW@%*&=zu2LbvsxZS&=+3tO3Ppt>YX{8%fbeV*Rz+QlgAKJo5wX=wnd@g!pTim8wI(c>36R2&*@x z_rl9dU48~Un{MJt>F{~JkG^g8OxZ3wSmy%g`C%6pcC4q}bsPAqO3w3352O)fe1;tCqd&;i2xd>H+qRmd|qGd zu1#ZcE>MY3E)|2>4N8v;Z%m0eF}yaIyZ#*Jx>Vbk>;z1jL%{JuR$~!FmR4EiCcGVC zw<)I5V2~AQWje6FOFrjpBFDSYL^HBDA78t3r*iV^xKPab@z=ambA`3=Qxe*lTOl-O z;#RJdfuE1B;+wZdU}9-;dR{rF@LXZN=V?CvN@;#=flI~r-YINPfRmw5c$(@)g{)ca zz^IULF@>~Ld&F+E#p%-g_}YDYqisd0b}|<0x7*!mQNZ)h|82GEBP!5dn@&)Z)$}ix zeEo)784+YMCpKWL3AYr+yIDWCOMT-je%C9YSW<;l!MRzULB?V3R1bIFl$BZo=EU+s ztFl6We!N(pkZr^BM>Tm2?8Ji($Kx*!#|SY(MnB(3y{j!jIv5O256Zo;5l7%qRRE`S+&Y(}Yd@d?kdg`qglq+NR>Q{+vy?Xew2^Z8v&2 zmienqPF72Yv&;!D!rIf)W@2vvEnscKPlLLBj_V6s%b=+X{Xf1qu57Q;i+EnI(_gLy zxj?A+lN`>v6`o`H2CM*p;bRoy@&aj!w9q%|m9Jpvoqjc93XWN+AohY){=WbK0RR6R kzajAe000I_L_t&o0MzD6q-ho@%m4rY07*qoM6N<$f;G(WbN~PV literal 0 HcmV?d00001 diff --git a/README.md b/README.md index b4593d6f..68e4992c 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@

IntroductionWhere To Start ◈ - Toolkits ◈ + Suite ComponentsDocumentation & ResourcesBindingsContributing @@ -18,18 +18,20 @@ --- -# IOTA Notarization And Audit Trail +# IOTA Notarization Suite ## Introduction -This repository contains two complementary IOTA ledger toolkits for verifiable on-chain data workflows: +This repository contains the IOTA Notarization Suite, a set of IOTA ledger tools for verifiable on-chain data workflows. -- **IOTA Notarization** - Best when you want a proof object for arbitrary data, documents, hashes, or latest-state notarization flows. -- **IOTA Audit Trail** - Best when you want shared audit records with sequential entries, role-based access control, locking, and tagging. +The suite includes: -Each toolkit is available as: +- **Single Notarization** + Use this for individual locked or dynamic notarizations of arbitrary data, documents, hashes, or latest-state records. +- **Audit Trails** + Use this for structured record histories with sequential entries, role-based access control, locking, and tagging. + +Each suite component is available as: - a **Move package** for the on-chain contracts - a **Rust SDK** for typed client access and transaction builders @@ -37,68 +39,68 @@ Each toolkit is available as: ## Where To Start -### I want to notarize data +### I want a single notarized record -Use **IOTA Notarization** when your main need is proving the existence, integrity, or latest state of data on-chain. +Use **Single Notarization** when your main need is proving the existence, integrity, or latest state of one notarized object on-chain. -- [Notarization Rust SDK](./notarization-rs) -- [Notarization Move Package](./notarization-move) -- [Notarization Wasm SDK](./bindings/wasm/notarization_wasm) -- [Notarization examples](./bindings/wasm/notarization_wasm/examples/README.md) +- [Single Notarization Rust SDK](./notarization-rs) +- [Single Notarization Move Package](./notarization-move) +- [Single Notarization Wasm SDK](./bindings/wasm/notarization_wasm) +- [Single Notarization examples](./bindings/wasm/notarization_wasm/examples/README.md) -### I want audit records +### I want an audit trail -Use **IOTA Audit Trail** when you need shared audit records with permissions, capabilities, tagging, and write or delete controls. +Use **Audit Trails** when you need a structured record history with permissions, capabilities, tagging, and write or delete controls. -- [Audit Trail Rust SDK](./audit-trail-rs) -- [Audit Trail Move Package](./audit-trail-move) -- [Audit Trail Wasm SDK](./bindings/wasm/audit_trail_wasm) -- [Audit Trail examples](./bindings/wasm/audit_trail_wasm/examples/README.md) +- [Audit Trails Rust SDK](./audit-trail-rs) +- [Audit Trails Move Package](./audit-trail-move) +- [Audit Trails Wasm SDK](./bindings/wasm/audit_trail_wasm) +- [Audit Trails examples](./bindings/wasm/audit_trail_wasm/examples/README.md) ### I want the on-chain contracts -- [Notarization Move](./notarization-move) -- [Audit Trail Move](./audit-trail-move) +- [Single Notarization Move](./notarization-move) +- [Audit Trails Move](./audit-trail-move) ### I want application SDKs -- [Notarization Rust](./notarization-rs) -- [Audit Trail Rust](./audit-trail-rs) -- [Notarization Wasm](./bindings/wasm/notarization_wasm) -- [Audit Trail Wasm](./bindings/wasm/audit_trail_wasm) +- [Single Notarization Rust](./notarization-rs) +- [Audit Trails Rust](./audit-trail-rs) +- [Single Notarization Wasm](./bindings/wasm/notarization_wasm) +- [Audit Trails Wasm](./bindings/wasm/audit_trail_wasm) -## Toolkits +## Suite Components -| Toolkit | Best for | Move Package | Rust SDK | Wasm SDK | -| ------------ | ------------------------------------------------------------------------ | ------------------------------------------ | -------------------------------------- | -------------------------------------------------------- | -| Notarization | Proof objects for documents, hashes, and updatable notarized state | [`notarization-move`](./notarization-move) | [`notarization-rs`](./notarization-rs) | [`notarization_wasm`](./bindings/wasm/notarization_wasm) | -| Audit Trail | Shared sequential records with roles, capabilities, tagging, and locking | [`audit-trail-move`](./audit-trail-move) | [`audit-trail-rs`](./audit-trail-rs) | [`audit_trail_wasm`](./bindings/wasm/audit_trail_wasm) | +| Component | Best for | Move Package | Rust SDK | Wasm SDK | +| -------------------- | ------------------------------------------------------------------------ | ------------------------------------------ | -------------------------------------- | -------------------------------------------------------- | +| Single Notarization | Individual locked or dynamic notarizations for documents, hashes, and state | [`notarization-move`](./notarization-move) | [`notarization-rs`](./notarization-rs) | [`notarization_wasm`](./bindings/wasm/notarization_wasm) | +| Audit Trails | Shared sequential records with roles, capabilities, tagging, and locking | [`audit-trail-move`](./audit-trail-move) | [`audit-trail-rs`](./audit-trail-rs) | [`audit_trail_wasm`](./bindings/wasm/audit_trail_wasm) | ### Which one should I use? -| Need | Best fit | -| ------------------------------------------------------------------------- | ------------ | -| Immutable or updatable proof object for arbitrary data | Notarization | -| Simple proof-of-existence or latest-state notarization flow | Notarization | -| Shared sequential records with roles, capabilities, and record tag policy | Audit Trail | -| Team or system audit log with governance and operational controls | Audit Trail | +| Need | Best fit | +| ------------------------------------------------------------------------- | -------------------- | +| Locked proof object for arbitrary data | Single Notarization | +| Dynamic latest-state notarization flow | Single Notarization | +| Shared sequential records with roles, capabilities, and record tag policy | Audit Trails | +| Team or system audit log with governance and operational controls | Audit Trails | ## Documentation And Resources -### IOTA Notarization +### Single Notarization -- [Notarization Rust SDK README](./notarization-rs/README.md) -- [Notarization Move Package README](./notarization-move/README.md) -- [Notarization Wasm README](./bindings/wasm/notarization_wasm/README.md) -- [Notarization examples](./bindings/wasm/notarization_wasm/examples/README.md) +- [Single Notarization Rust SDK README](./notarization-rs/README.md) +- [Single Notarization Move Package README](./notarization-move/README.md) +- [Single Notarization Wasm README](./bindings/wasm/notarization_wasm/README.md) +- [Single Notarization examples](./bindings/wasm/notarization_wasm/examples/README.md) - [IOTA Notarization Docs Portal](https://docs.iota.org/developer/iota-notarization) -### IOTA Audit Trail +### Audit Trails -- [Audit Trail Rust SDK README](./audit-trail-rs/README.md) -- [Audit Trail Move Package README](./audit-trail-move/README.md) -- [Audit Trail Wasm README](./bindings/wasm/audit_trail_wasm/README.md) -- [Audit Trail examples](./bindings/wasm/audit_trail_wasm/examples/README.md) +- [Audit Trails Rust SDK README](./audit-trail-rs/README.md) +- [Audit Trails Move Package README](./audit-trail-move/README.md) +- [Audit Trails Wasm README](./bindings/wasm/audit_trail_wasm/README.md) +- [Audit Trails examples](./bindings/wasm/audit_trail_wasm/examples/README.md) ### Shared @@ -108,12 +110,12 @@ Use **IOTA Audit Trail** when you need shared audit records with permissions, ca [Foreign Function Interface (FFI)](https://en.wikipedia.org/wiki/Foreign_function_interface) bindings available in this repository: -- [Web Assembly for IOTA Notarization](./bindings/wasm/notarization_wasm) -- [Web Assembly for IOTA Audit Trail](./bindings/wasm/audit_trail_wasm) +- [Web Assembly for Single Notarization](./bindings/wasm/notarization_wasm) +- [Web Assembly for Audit Trails](./bindings/wasm/audit_trail_wasm) ## Contributing -We would love to have you help us with the development of IOTA Notarization and Audit Trail. Each and every contribution is greatly valued. +We would love to have you help us with the development of the IOTA Notarization Suite. Each and every contribution is greatly valued. Please review the [contribution](https://docs.iota.org/developer/iota-notarization/contribute) sections in the [IOTA Docs Portal](https://docs.iota.org/developer/iota-notarization/). diff --git a/audit-trail-rs/README.md b/audit-trail-rs/README.md index f02ca754..112b069b 100644 --- a/audit-trail-rs/README.md +++ b/audit-trail-rs/README.md @@ -1,46 +1,251 @@ -![banner](https://github.com/iotaledger/notarization/raw/HEAD/.github/banner_notarization.png) +# IOTA Audit Trails Rust SDK -

- StackExchange - Discord - Apache 2.0 license -

+## Introduction -

- Introduction ◈ - Documentation & Resources ◈ - Bindings ◈ - Contributing -

+The Audit Trails Rust SDK is the Rust client for structured record histories in the IOTA Notarization Suite. ---- +The SDK provides an `AuditTrailBuilder` that creates audit trail objects on the IOTA ledger and an `AuditTrailHandle` +that interacts with existing trails. The handle maps to one on-chain audit trail and provides typed APIs for records, +access control, locking, tags, metadata, migration, and deletion. -# IOTA Audit Trail Rust SDK +Use Audit Trails when you need a governed record history with sequential entries, role-based permissions, capabilities, +locking, and tagging. Use Single Notarization when you need one locked or dynamic notarized object for arbitrary data, +documents, hashes, or latest-state records. -## Introduction +You can find the full IOTA Notarization Suite documentation [here](https://docs.iota.org/developer/iota-notarization). + +## Process Flows + +The following workflows demonstrate how `AuditTrailBuilder` and `AuditTrailHandle` instances create, update, govern, and +delete audit trail objects on the ledger. + +### Creating an Audit Trail + +An _Audit Trail_ is created on the ledger using the `AuditTrailClient::create_trail()` function. To create an _Audit +Trail_, specify the following initial arguments with the `AuditTrailBuilder` setter functions. The terms used here are +defined in the [glossary below](#glossary). + +- Optional `Initial Record` that becomes sequence number `0` +- Optional `Immutable Metadata` +- Optional `Updatable Metadata` +- Optional `Locking Config` +- Optional `Record Tag Registry` +- Optional initial admin address + +After an _Audit Trail_ has been created, the creator receives an Admin capability object. This capability authorizes +administrative operations such as defining roles, issuing capabilities, updating locks, managing tags, and deleting the +trail. + +#### Creating a new Audit Trail on the Ledger + +The following sequence diagram explains the interaction between the involved technical components and the `Admin` when an +_Audit Trail_ is created on the ledger: + +```mermaid +sequenceDiagram + actor Admin + participant Lib as Rust-Library + participant Move as Move-SC + participant Net as Iota-Network + Admin ->>+ Lib: fn AuditTrailClientReadOnly::new(iota_client) + Lib ->>- Admin: AuditTrailClientReadOnly + Admin ->>+ Lib: fn AuditTrailClient::new(read_only_client, signer) + Lib ->>- Admin: AuditTrailClient + Admin ->>+ Lib: fn AuditTrailClient::create_trail() + Lib ->>- Admin: AuditTrailBuilder + Admin ->> Lib: fn AuditTrailBuilder::with_trail_metadata(metadata) + Admin ->> Lib: fn AuditTrailBuilder::with_updatable_metadata(metadata) + Admin ->> Lib: fn AuditTrailBuilder::with_initial_record(record) + Admin ->>+ Lib: fn AuditTrailBuilder::finish() + Lib ->>- Admin: TransactionBuilder + Admin ->>+ Lib: fn TransactionBuilder::build_and_execute() + Note right of Admin: Alternatively fn execute_with_gas_station()
can be used to execute via Gas Station + Note right of Admin: Alternatively fn build()
can be used to only return the TransactionData and signatures + Lib ->>+ Move: main::create() + Move ->> Net: transfer::transfer(trail, sender) + Move ->> Net: transfer::transfer(admin_capability, admin) + Move ->>- Lib: TX Response + Lib ->>- Admin: TrailCreated + IotaTransactionBlockResponse +``` + +### Adding And Reading Records + +Records are managed through the trail-scoped record API returned by `AuditTrailHandle::records()`. A record append uses +`TrailRecords::add()`, while read paths use `TrailRecords::get()`, `TrailRecords::record_count()`, `TrailRecords::list()`, +or `TrailRecords::list_page()`. + +To add a record, the sender must hold a capability whose role allows record writes. Tagged records must use a tag already +defined in the trail's tag registry, and the sender's role must allow that tag. + +#### Appending a Record to an Existing Audit Trail + +The following sequence diagram shows the component interaction when a `Record Admin` appends a new record: + +```mermaid +sequenceDiagram + actor RecordAdmin + participant Lib as Rust-Library + participant Move as Move-SC + participant Net as Iota-Network + RecordAdmin ->>+ Lib: fn AuditTrailClient::trail(trail_id) + Lib ->>- RecordAdmin: AuditTrailHandle + RecordAdmin ->>+ Lib: fn AuditTrailHandle::records() + Lib ->>- RecordAdmin: TrailRecords + RecordAdmin ->>+ Lib: fn TrailRecords::add(data, metadata, tag) + Lib ->>- RecordAdmin: TransactionBuilder + RecordAdmin ->>+ Lib: fn TransactionBuilder::build_and_execute() + Lib ->>+ Move: main::add_record() + Move ->> Move: validate capability, lock state, and tag policy + Move ->> Net: append record at next sequence number + Move ->> Net: event::emit(RecordAdded) + Move ->>- Lib: TX Response + Lib ->>- RecordAdmin: RecordAdded + IotaTransactionBlockResponse +``` + +#### Reading Records from an Existing Audit Trail + +The following sequence diagram explains the component interaction for `Verifiers` or other parties fetching trail +records: + +```mermaid +sequenceDiagram + actor Verifier + participant Lib as Rust-Library + participant Net as Iota-Network + Verifier ->>+ Lib: fn AuditTrailClientReadOnly::trail(trail_id) + Lib ->>- Verifier: AuditTrailHandle + Verifier ->>+ Lib: fn AuditTrailHandle::records() + Lib ->>- Verifier: TrailRecords + Verifier ->>+ Lib: fn TrailRecords::get(sequence_number) + Lib -->> Net: RPC Calls + Net -->> Lib: Record Data + Lib ->>- Verifier: Record +``` + +### Managing Access + +Access control is managed through the trail-scoped access API returned by `AuditTrailHandle::access()`. Roles define +which permissions are allowed, and capability objects delegate those roles to users or services. + +The built-in Admin role is initialized when the trail is created. Additional roles can be created with +`TrailAccess::for_role(name).create(...)`, updated with `update_permissions(...)`, and delegated with +`issue_capability(...)`. Issued capabilities can also be revoked, destroyed, or constrained by address and validity +window. + +#### Defining a Role and Issuing a Capability + +The following sequence diagram shows the component interaction when an `Admin` defines a role and issues a capability: + +```mermaid +sequenceDiagram + actor Admin + participant Lib as Rust-Library + participant Move as Move-SC + participant Net as Iota-Network + Admin ->>+ Lib: fn AuditTrailClient::trail(trail_id) + Lib ->>- Admin: AuditTrailHandle + Admin ->>+ Lib: fn AuditTrailHandle::access() + Lib ->>- Admin: TrailAccess + Admin ->>+ Lib: fn TrailAccess::for_role("RecordAdmin") + Lib ->>- Admin: RoleHandle + Admin ->>+ Lib: fn RoleHandle::create(permissions, role_tags) + Lib ->>- Admin: TransactionBuilder + Admin ->>+ Lib: fn TransactionBuilder::build_and_execute() + Lib ->>+ Move: main::create_role() + Move ->> Net: event::emit(RoleCreated) + Move ->>- Lib: TX Response + Admin ->>+ Lib: fn RoleHandle::issue_capability(options) + Lib ->>- Admin: TransactionBuilder + Admin ->>+ Lib: fn TransactionBuilder::build_and_execute() + Lib ->>+ Move: main::new_capability() + Move ->> Net: transfer::transfer(capability, issued_to) + Move ->> Net: event::emit(CapabilityIssued) + Move ->>- Lib: TX Response + Lib ->>- Admin: CapabilityIssued + IotaTransactionBlockResponse +``` + +### Locking And Deletion + +Locking is managed through the trail-scoped locking API returned by `AuditTrailHandle::locking()`. The lock configuration +controls three independent behaviors: + +- when records can be deleted +- when the entire trail can be deleted +- when new records can be written + +An audit trail can be deleted only after its records are removed and the delete-trail lock allows deletion. Records can +be deleted individually with `TrailRecords::delete()` or in batches with `TrailRecords::delete_records_batch()`. + +#### Updating Locking Rules + +The following sequence diagram shows the component interaction when a `Locking Admin` updates the write lock: + +```mermaid +sequenceDiagram + actor LockingAdmin + participant Lib as Rust-Library + participant Move as Move-SC + participant Net as Iota-Network + LockingAdmin ->>+ Lib: fn AuditTrailClient::trail(trail_id) + Lib ->>- LockingAdmin: AuditTrailHandle + LockingAdmin ->>+ Lib: fn AuditTrailHandle::locking() + Lib ->>- LockingAdmin: TrailLocking + LockingAdmin ->>+ Lib: fn TrailLocking::update_write_lock(lock) + Lib ->>- LockingAdmin: TransactionBuilder + LockingAdmin ->>+ Lib: fn TransactionBuilder::build_and_execute() + Lib ->>+ Move: main::update_write_lock() + Move ->> Move: validate capability and requested lock + Move ->> Net: update trail locking config + Move ->>- Lib: TX Response + Lib ->>- LockingAdmin: () + IotaTransactionBlockResponse +``` + +#### Deleting an Audit Trail -`audit_trail` is the Rust SDK for reading and writing audit trails on the IOTA ledger. +The lifecycle of an _Audit Trail_ deletion can be described as: -An audit trail is a shared on-chain object that stores a sequential series of records together with: +- Delete all unlocked records with `TrailRecords::delete()` or `TrailRecords::delete_records_batch()` +- Wait until the `Delete Trail Lock` allows trail deletion, if a lock is configured +- Delete the trail object with `AuditTrailHandle::delete_audit_trail()` -- role-based access control backed by capabilities -- trail-level locking rules for writes and deletions -- tag registries for record categorization -- immutable creation metadata and optional updatable metadata +The trail deletion process does not remove records automatically. The trail must be empty before +`delete_audit_trail()` can succeed. -The crate provides: +## Glossary -- read-only and signing client wrappers for the on-chain audit-trail package -- typed trail handles for records, locking, access control, and tags -- serializable Rust representations of on-chain objects and emitted events -- transaction builders that integrate with the shared `product_common` transaction flow +- `Audit Trail`: A shared on-chain object that stores ordered records, metadata, locking configuration, tag registry, + roles, and capability state. +- `Record`: A single trail entry stored at a sequence number. Records contain `Data`, optional record metadata, an + optional tag, and creation information. +- `Initial Record`: An optional record created together with the trail. When present, it is stored at sequence number + `0`. +- `Sequence Number`: The numeric position of a record inside a trail. Sequence numbers are used to fetch, delete, and + reason about records. +- `Admin Capability`: The capability object created at trail creation time. It authorizes administrative operations for + the trail. +- `Role`: A named permission set stored inside the trail. Roles define which operations a capability holder may perform. +- `Permission Set`: A collection of permissions such as adding records, deleting records, updating locks, managing tags, + managing metadata, or managing capabilities. +- `Capability`: An owned object that grants one role for one audit trail. Capabilities can optionally be restricted to an + address or a validity window. +- `Record Tag Registry`: The trail-owned list of tags that records may use. Tagged writes must reference a registered + tag. +- `Role Tags`: Optional role-scoped tag restrictions. They narrow which tagged records a role may operate on. +- `Locking Config`: The active locking rules for record deletion, trail deletion, and record writes. +- `Delete Record Window`: A locking rule that controls when individual records can be deleted. +- `Delete Trail Lock`: A time lock that controls when the entire trail can be deleted. +- `Write Lock`: A time lock that controls when new records can be added. +- `Immutable Metadata`: Optional metadata stored at creation time and never updated after the trail is created. +- `Updatable Metadata`: Optional metadata stored on the trail that can be replaced or cleared after creation. +- `Trail Handle`: The typed Rust handle returned by `AuditTrailClient::trail(trail_id)`. It scopes record, access, + locking, tag, metadata, migration, and deletion operations to one audit trail. ## Documentation And Resources -- [Audit Trail Move Package](https://github.com/iotaledger/notarization/tree/main/audit-trail-move): On-chain contract package that defines the shared object model, permissions, locking, and events. -- [Wasm SDK](https://github.com/iotaledger/notarization/tree/main/bindings/wasm/audit_trail_wasm): JavaScript and TypeScript bindings for browser and Node.js integrations. -- [Wasm Examples](https://github.com/iotaledger/notarization/tree/main/bindings/wasm/audit_trail_wasm/examples/README.md): Runnable audit-trail examples for JS and TS consumers. -- [Repository Examples](https://github.com/iotaledger/notarization/tree/main/examples/README.md): End-to-end examples across the broader repository. +- [Audit Trails Move Package](https://github.com/iotaledger/notarization/tree/main/audit-trail-move): On-chain contract package that defines the shared object model, permissions, locking, and events. +- [Audit Trails Wasm SDK](https://github.com/iotaledger/notarization/tree/main/bindings/wasm/audit_trail_wasm): JavaScript and TypeScript bindings for browser and Node.js integrations. +- [Audit Trails Wasm Examples](https://github.com/iotaledger/notarization/tree/main/bindings/wasm/audit_trail_wasm/examples/README.md): Runnable audit-trail examples for JS and TS consumers. +- [Repository Examples](https://github.com/iotaledger/notarization/tree/main/examples/README.md): End-to-end examples across the Notarization Suite. This README is also used as the crate-level rustdoc entry point, while the source files provide detailed API documentation for all public types and methods. @@ -52,7 +257,7 @@ This README is also used as the crate-level rustdoc entry point, while the sourc ## Contributing -We would love to have you help us with the development of IOTA Audit Trail. Each and every contribution is greatly valued. +We would love to have you help us with the development of the IOTA Notarization Suite. Each and every contribution is greatly valued. Please review the [contribution](https://docs.iota.org/developer/iota-notarization/contribute) sections in the [IOTA Docs Portal](https://docs.iota.org/developer/iota-notarization/). diff --git a/notarization-rs/README.md b/notarization-rs/README.md index 75be16ba..b15fafdf 100644 --- a/notarization-rs/README.md +++ b/notarization-rs/README.md @@ -1,21 +1,27 @@ -# IOTA Notarization +# IOTA Single Notarization Rust SDK -The Notarization Rust library provides a `NotarizationBuilder` that can be used to create Notarization objects on -the IOTA ledger or to use an already existing Notarization object. The NotarizationBuilder returns a Notarization struct -instance, which is mapped to the Notarization object on the ledger and can be used to interact with the object. +The Single Notarization Rust SDK is the Rust client for individual locked and dynamic notarizations in the IOTA +Notarization Suite. -You can find the full IOTA Notarization documentation [here](https://docs.iota.org/developer/iota-notarization). +The SDK provides a `NotarizationBuilder` that creates notarization objects on the IOTA ledger or connects to existing +notarization objects. The builder returns a `Notarization` struct instance that maps to the on-chain object and provides +typed methods for interacting with it. + +Use Single Notarization when you need one notarized object for arbitrary data, documents, hashes, or latest-state +records. Use Audit Trails when you need a structured record history with roles, capabilities, locking, and tagging. + +You can find the full IOTA Notarization Suite documentation [here](https://docs.iota.org/developer/iota-notarization). ## Process Flows -The following workflows demonstrate how NotarizationBuilder and Notarization instances can be used to create, update and -destroy Notarization objects on the ledger. +The following workflows demonstrate how `NotarizationBuilder` and `Notarization` instances create, update, and destroy +single notarization objects on the ledger. ### Dynamic Notarizations A _Dynamic Notarization_ is created on the ledger using the `NotarizationBuilder::create_dynamic()` function. -To create a _Dynamic Notarization_, the following initial arguments need to be specified using the NotarizationBuilder -setter functions (The used terms can be found in the [glossary below](#glossary)): +To create a _Dynamic Notarization_, specify the following initial arguments with the `NotarizationBuilder` setter +functions. The terms used here are defined in the [glossary below](#glossary). - Initial State consisting of `Stored Data` and `State Metadata` that will be used to define the first version of the Notarization state. @@ -23,8 +29,8 @@ setter functions (The used terms can be found in the [glossary below](#glossary) - Optional `Updatable Metadata` (**Dynamic**: always updatable; **Locked**: immutable) - An optional boolean indicator if the Notarization shall be transferable -After a **dynamic** Notarization has been created, it can be updated using the `Notarization::update_state()` function and can be -destroyed using `Notarization::destroy()`. +After a **dynamic** Notarization has been created, it can be updated using the `Notarization::update_state()` function +and destroyed using `Notarization::destroy()`. **Locked** notarizations are immutable after creation. #### Creating a new Dynamic Notarization on the Ledger @@ -60,7 +66,7 @@ sequenceDiagram Lib ->>- Prover: OnChainNotarization + IotaTransactionBlockResponse ``` -#### Fetching state data from a Notarization already existing on the ledger +#### Fetching state data from an existing Notarization on the ledger The following sequence diagram explains the component interaction for `Verifiers` (or other parties) fetching the `Latest State`: @@ -79,7 +85,7 @@ sequenceDiagram Lib ->>- Verifier: State ``` -#### Updating state data of a Notarization already existing on the ledger +#### Updating state data of an existing Notarization on the ledger The following sequence diagram shows the component interaction in case a `Prover` wants to update the `Latest State` of a Notarization: @@ -111,17 +117,18 @@ sequenceDiagram ### Locked Notarizations -In general _Locked Notarizations_ are handled similar to _Dynamic Notarizations_. A `NotarizationBuilder` for _Locked Notarization_ is created -using the `NotarizationClient::create_locked_notarization()` function. The resulting `NotarizationBuilder` can be used to -create the _Locked Notarization_ on the ledger using the `NotarizationBuilder::finish()` function. +In general, _Locked Notarizations_ are handled similarly to _Dynamic Notarizations_. A `NotarizationBuilder` for a +_Locked Notarization_ is created using the `NotarizationClient::create_locked_notarization()` function. The resulting +`NotarizationBuilder` can be used to create the _Locked Notarization_ on the ledger using the +`NotarizationBuilder::finish()` function. -To create a _Locked Notarization_ the following arguments need to be specified using the `NotarizationBuilder` setter +To create a _Locked Notarization_, specify the following arguments with the `NotarizationBuilder` setter functions: - all arguments needed to create a _Dynamic Notarization_ - Optional Delete Timelock -After the _Locked Notarization_ has been created - by design - the `Latest State` can not bee updated anymore. +After the _Locked Notarization_ has been created, the `Latest State` cannot be updated by design. The lifecycle of a _Locked Notarization_ can be described as: @@ -129,7 +136,7 @@ The lifecycle of a _Locked Notarization_ can be described as: - If a `Delete Timelock` has been used, wait at least until the time-lock has expired - Destroy the Notarization object -As the `Latest State` of a _Locked Notarization_ can not be updated the lifecycle doesn’t include any update processes. +As the `Latest State` of a _Locked Notarization_ cannot be updated, the lifecycle does not include any update processes. ## Glossary @@ -139,12 +146,12 @@ As the `Latest State` of a _Locked Notarization_ can not be updated the lifecycl data; each update completely overwrites the previous stored data. - `Ledger Object`: A single, updatable on-chain object that holds the `Latest State` of the notarized data. It is identified by a unique ObjectId and is modified through update transactions. -- `Transfer Timelock`k: An optional time-locking period during which the `Ledger Object` can not be transfered. -- `Delete Timelock`: An optional time-locking period during which the Ledger Object can not be deleted. +- `Transfer Timelock`: An optional time-locking period during which the `Ledger Object` cannot be transferred. +- `Delete Timelock`: An optional time-locking period during which the `Ledger Object` cannot be deleted. - `State Metadata`: An optional text describing the `Stored Data`. For example, if document hashes of succeeding revisions of a document are stored as `Stored Data`, State Metadata can be used to describe the revision specifier of the document. -- `Latest State`: The most recent version of the `Stored Data` (and optionally theState Metadata) within the +- `Latest State`: The most recent version of the `Stored Data` (and optionally the `State Metadata`) within the `Ledger Object`. In _Dynamic Notarization_, only this latest state is visible on-chain, as previous states are overwritten. As the `Stored Data` and optionally the `State Metadata` together build the `Latest State` they can only be updated together in one function call. @@ -159,16 +166,16 @@ As the `Latest State` of a _Locked Notarization_ can not be updated the lifecycl immutability. - `Immutable Description`: An arbitrary informational String that can be used for example to describe the purpose of the created _Dynamic Notarization_ object, how often it will be updated or other legally important or useful information. - The `Immutable Description` is specified by the `Prover` at creation time and can not be updated after the Notarization - abject has been created. + The `Immutable Description` is specified by the `Prover` at creation time and cannot be updated after the Notarization + object has been created. - `Creation Timestamp`: Indicates when the `Ledger Object` was initially created. - `Immutable Metadata`: Consists of the `Immutable Description` and `Creation Timestamp`. - `Updatable Metadata`: An arbitrary informational String that can be updated at any time by the `Prover` independently - from the `Latest State` (dynamic notarizations only; locked notarizations are immutable). Can be used to provide additional useful information that are subject to change from time to - time. + from the `Latest State` (dynamic notarizations only; locked notarizations are immutable). Can be used to provide + additional useful information that is subject to change from time to time. - `State Version Count`: Numerical value incremented with each update of the `Latest State`. -- `Last State Change Time`: Indicates when the `Latest State` has been updated the last time. +- `Last State Change Time`: Indicates when the `Latest State` was last updated. - `Calculated Metadata`: Consists of the `State Version Count` and `Last State Change Time` -- `Notarized Record`: Some information owned by the `Prover` that describe and include notarized data, so that these data +- `Notarized Record`: Some information owned by the `Prover` that describes and includes notarized data, so that this data can be verified by a `Verifier`. In the context of the _Dynamic Notarization_ method, the latest version of subsequent versions of a `Notarized Record` is the `Latest State`. From 9e64fb44a7627bf9319a8a6c6e8351f43a13dd74 Mon Sep 17 00:00:00 2001 From: Yasir Date: Fri, 1 May 2026 11:51:01 +0300 Subject: [PATCH 175/189] docs: update title in README for clarity --- notarization-rs/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/notarization-rs/README.md b/notarization-rs/README.md index b15fafdf..a208d191 100644 --- a/notarization-rs/README.md +++ b/notarization-rs/README.md @@ -1,4 +1,4 @@ -# IOTA Single Notarization Rust SDK +# IOTA Single Notarization The Single Notarization Rust SDK is the Rust client for individual locked and dynamic notarizations in the IOTA Notarization Suite. From 1fe4a92b27c1747ec20d1ce0b371e18c9f251d95 Mon Sep 17 00:00:00 2001 From: Yasir Date: Fri, 1 May 2026 11:58:13 +0300 Subject: [PATCH 176/189] Fix Markdown table alignment in README --- README.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 68e4992c..a88efcc5 100644 --- a/README.md +++ b/README.md @@ -71,19 +71,19 @@ Use **Audit Trails** when you need a structured record history with permissions, ## Suite Components -| Component | Best for | Move Package | Rust SDK | Wasm SDK | -| -------------------- | ------------------------------------------------------------------------ | ------------------------------------------ | -------------------------------------- | -------------------------------------------------------- | -| Single Notarization | Individual locked or dynamic notarizations for documents, hashes, and state | [`notarization-move`](./notarization-move) | [`notarization-rs`](./notarization-rs) | [`notarization_wasm`](./bindings/wasm/notarization_wasm) | -| Audit Trails | Shared sequential records with roles, capabilities, tagging, and locking | [`audit-trail-move`](./audit-trail-move) | [`audit-trail-rs`](./audit-trail-rs) | [`audit_trail_wasm`](./bindings/wasm/audit_trail_wasm) | +| Component | Best for | Move Package | Rust SDK | Wasm SDK | +| ------------------- | --------------------------------------------------------------------------- | ------------------------------------------ | -------------------------------------- | -------------------------------------------------------- | +| Single Notarization | Individual locked or dynamic notarizations for documents, hashes, and state | [`notarization-move`](./notarization-move) | [`notarization-rs`](./notarization-rs) | [`notarization_wasm`](./bindings/wasm/notarization_wasm) | +| Audit Trails | Shared sequential records with roles, capabilities, tagging, and locking | [`audit-trail-move`](./audit-trail-move) | [`audit-trail-rs`](./audit-trail-rs) | [`audit_trail_wasm`](./bindings/wasm/audit_trail_wasm) | ### Which one should I use? -| Need | Best fit | -| ------------------------------------------------------------------------- | -------------------- | -| Locked proof object for arbitrary data | Single Notarization | -| Dynamic latest-state notarization flow | Single Notarization | -| Shared sequential records with roles, capabilities, and record tag policy | Audit Trails | -| Team or system audit log with governance and operational controls | Audit Trails | +| Need | Best fit | +| ------------------------------------------------------------------------- | ------------------- | +| Locked proof object for arbitrary data | Single Notarization | +| Dynamic latest-state notarization flow | Single Notarization | +| Shared sequential records with roles, capabilities, and record tag policy | Audit Trails | +| Team or system audit log with governance and operational controls | Audit Trails | ## Documentation And Resources From fbd19429e304fd84d6b4515bf38145e1b763eee4 Mon Sep 17 00:00:00 2001 From: Yasir Date: Tue, 5 May 2026 14:50:49 +0300 Subject: [PATCH 177/189] Skip unauthorized tagged records in batch deletes --- audit-trail-move/Move.lock | 8 +-- audit-trail-move/sources/audit_trail.move | 27 +++++---- audit-trail-move/tests/record_tests.move | 23 +++++++- audit-trail-rs/README.md | 2 +- audit-trail-rs/src/core/records/mod.rs | 4 +- audit-trail-rs/src/core/records/operations.rs | 4 +- audit-trail-rs/tests/e2e/records.rs | 56 +++++++++++-------- 7 files changed, 77 insertions(+), 47 deletions(-) diff --git a/audit-trail-move/Move.lock b/audit-trail-move/Move.lock index b36d8ec9..9504e416 100644 --- a/audit-trail-move/Move.lock +++ b/audit-trail-move/Move.lock @@ -54,16 +54,16 @@ dependencies = [ ] [move.toolchain-version] -compiler-version = "1.20.0-rc" +compiler-version = "1.22.1-rc" edition = "2024.beta" flavor = "iota" [env] [env.localnet] -chain-id = "417321d4" -original-published-id = "0x70e6f4f0ba0d3fae15288bf254b34829ad3c122b7f73766b13233af6acaeb715" -latest-published-id = "0x70e6f4f0ba0d3fae15288bf254b34829ad3c122b7f73766b13233af6acaeb715" +chain-id = "c8ba4765" +original-published-id = "0xf0a9009527072c34f56f34d8cf96218c3a2e008f30c568c55c08df01be19f51d" +latest-published-id = "0xf0a9009527072c34f56f34d8cf96218c3a2e008f30c568c55c08df01be19f51d" published-version = "1" [env.testnet] diff --git a/audit-trail-move/sources/audit_trail.move b/audit-trail-move/sources/audit_trail.move index 076f0e65..9e24f983 100644 --- a/audit-trail-move/sources/audit_trail.move +++ b/audit-trail-move/sources/audit_trail.move @@ -276,18 +276,18 @@ entry fun migrate( self.version = PACKAGE_VERSION; } -fun assert_record_tag_allowed( +fun record_tag_allowed( self: &AuditTrail, cap: &Capability, tag: &Option, -) { +): bool { if (tag.is_none()) { - return + return true }; let requested_tag = option::borrow(tag); assert!(record_tags::contains(&self.tags, requested_tag), ERecordTagNotDefined); - assert!(record_tags::role_allows(&self.roles, cap, requested_tag), ERecordTagNotAllowed); + record_tags::role_allows(&self.roles, cap, requested_tag) } // ===== Record Operations ===== @@ -315,7 +315,7 @@ public fun add_record( ctx, ); assert!(!locking::is_write_locked(&self.locking_config, clock), ETrailWriteLocked); - assert_record_tag_allowed(self, cap, &record_tag); + assert!(record_tag_allowed(self, cap, &record_tag), ERecordTagNotAllowed); let caller = ctx.sender(); let timestamp = clock::timestamp_ms(clock); @@ -371,10 +371,13 @@ public fun delete_record( ctx, ); assert!(linked_table::contains(&self.records, sequence_number), ERecordNotFound); - assert_record_tag_allowed( - self, - cap, - record::tag(linked_table::borrow(&self.records, sequence_number)), + assert!( + record_tag_allowed( + self, + cap, + record::tag(linked_table::borrow(&self.records, sequence_number)), + ), + ERecordTagNotAllowed, ); assert!(!self.is_record_locked(sequence_number, clock), ERecordLocked); @@ -432,11 +435,13 @@ public fun delete_records_batch( continue }; - assert_record_tag_allowed( + if (!record_tag_allowed( self, cap, record::tag(linked_table::borrow(&self.records, sequence_number)), - ); + )) { + continue + }; let record = linked_table::remove(&mut self.records, sequence_number); if (record::tag(&record).is_some()) { diff --git a/audit-trail-move/tests/record_tests.move b/audit-trail-move/tests/record_tests.move index dd0e85c2..529f7a36 100644 --- a/audit-trail-move/tests/record_tests.move +++ b/audit-trail-move/tests/record_tests.move @@ -492,8 +492,7 @@ fun test_delete_tagged_record_requires_matching_role_tags() { } #[test] -#[expected_failure(abort_code = audit_trail::main::ERecordTagNotAllowed)] -fun test_delete_records_batch_requires_matching_role_tags() { +fun test_delete_records_batch_skips_records_without_matching_role_tags() { let admin = @0xAD; let mut scenario = ts::begin(admin); @@ -566,6 +565,14 @@ fun test_delete_records_batch_requires_matching_role_tags() { let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); clock.set_for_testing(initial_time_for_testing() + 1000); + trail.add_record( + &tagged_writer_cap, + record::new_text(string::utf8(b"Untagged record")), + std::option::none(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); trail.add_record( &tagged_writer_cap, record::new_text(string::utf8(b"Tagged record")), @@ -586,7 +593,17 @@ fun test_delete_records_batch_requires_matching_role_tags() { let mut clock = clock::create_for_testing(ts::ctx(&mut scenario)); clock.set_for_testing(initial_time_for_testing() + 2000); - trail.delete_records_batch(&delete_all_cap, 10, &clock, ts::ctx(&mut scenario)); + let deleted = trail.delete_records_batch( + &delete_all_cap, + 10, + &clock, + ts::ctx(&mut scenario), + ); + assert!(vector::length(&deleted) == 1, 0); + assert!(*vector::borrow(&deleted, 0) == 0, 1); + assert!(trail.record_count() == 1, 2); + assert!(!trail.has_record(0), 3); + assert!(trail.has_record(1), 4); cleanup_capability_trail_and_clock(&scenario, delete_all_cap, trail, clock); }; diff --git a/audit-trail-rs/README.md b/audit-trail-rs/README.md index 112b069b..07f59ed8 100644 --- a/audit-trail-rs/README.md +++ b/audit-trail-rs/README.md @@ -204,7 +204,7 @@ sequenceDiagram The lifecycle of an _Audit Trail_ deletion can be described as: -- Delete all unlocked records with `TrailRecords::delete()` or `TrailRecords::delete_records_batch()` +- Delete all eligible unlocked records with `TrailRecords::delete()` or `TrailRecords::delete_records_batch()` - Wait until the `Delete Trail Lock` allows trail deletion, if a lock is configured - Delete the trail object with `AuditTrailHandle::delete_audit_trail()` diff --git a/audit-trail-rs/src/core/records/mod.rs b/audit-trail-rs/src/core/records/mod.rs index 5353fecc..5e3de837 100644 --- a/audit-trail-rs/src/core/records/mod.rs +++ b/audit-trail-rs/src/core/records/mod.rs @@ -112,8 +112,8 @@ impl<'a, C, D> TrailRecords<'a, C, D> { /// Builds a transaction that deletes up to `limit` records in one operation. /// - /// Batch deletion requires `DeleteAllRecords`, skips locked records, and removes up to `limit` unlocked records - /// in trail order. + /// Batch deletion requires `DeleteAllRecords`, skips locked records and records outside the capability's tag + /// access, and removes up to `limit` eligible records in trail order. pub fn delete_records_batch(&self, limit: u64) -> TransactionBuilder where C: AuditTrailFull + CoreClient, diff --git a/audit-trail-rs/src/core/records/operations.rs b/audit-trail-rs/src/core/records/operations.rs index 471ce99f..11a6ce8c 100644 --- a/audit-trail-rs/src/core/records/operations.rs +++ b/audit-trail-rs/src/core/records/operations.rs @@ -113,8 +113,8 @@ impl RecordsOps { /// Builds the `delete_records_batch` call. /// - /// Batch deletion requires `DeleteAllRecords`, skips locked records, and deletes up to `limit` unlocked records - /// in trail order. + /// Batch deletion requires `DeleteAllRecords`, skips locked records and records outside the capability's tag + /// access, and deletes up to `limit` eligible records in trail order. pub(super) async fn delete_records_batch( client: &C, trail_id: ObjectID, diff --git a/audit-trail-rs/tests/e2e/records.rs b/audit-trail-rs/tests/e2e/records.rs index d72610ec..73dd1a49 100644 --- a/audit-trail-rs/tests/e2e/records.rs +++ b/audit-trail-rs/tests/e2e/records.rs @@ -1115,11 +1115,12 @@ async fn delete_records_batch_requires_delete_all_records_permission() -> anyhow } #[tokio::test] -async fn delete_records_batch_requires_matching_role_tag_access() -> anyhow::Result<()> { +async fn delete_records_batch_skips_unauthorized_tagged_records() -> anyhow::Result<()> { let client = get_funded_test_client().await?; let trail_id = client .create_test_trail_with_tags(Data::text("batch-delete-tagged-deny"), ["finance"]) .await?; + let records = client.trail(trail_id).records(); client .create_role( @@ -1130,7 +1131,12 @@ async fn delete_records_batch_requires_matching_role_tag_access() -> anyhow::Res ) .await?; client - .create_role(trail_id, "DeleteAllWithoutTags", [Permission::DeleteAllRecords], None) + .create_role( + trail_id, + "DeleteAllWithoutTags", + [Permission::DeleteRecord, Permission::DeleteAllRecords], + None, + ) .await?; client .issue_cap(trail_id, "TaggedWriter", CapabilityIssueOptions::default()) @@ -1139,38 +1145,30 @@ async fn delete_records_batch_requires_matching_role_tag_access() -> anyhow::Res .issue_cap(trail_id, "DeleteAllWithoutTags", CapabilityIssueOptions::default()) .await?; - client - .trail(trail_id) - .records() + records.delete(0).build_and_execute(&client).await?; + records .add(Data::text("tagged record"), None, Some("finance".to_string())) .build_and_execute(&client) .await?; - let denied = client - .trail(trail_id) - .records() + let deleted = records .delete_records_batch(10) .build_and_execute(&client) - .await; + .await? + .output; - assert!( - denied.is_err(), - "tagged batch deletes should require matching role tag access" - ); - assert_eq!(client.trail(trail_id).records().record_count().await?, 2); - assert_eq!( - client.trail(trail_id).records().get(1).await?.tag.as_deref(), - Some("finance") - ); + assert!(deleted.is_empty()); + assert_eq!(records.record_count().await?, 1); + assert_eq!(records.get(1).await?.tag.as_deref(), Some("finance")); Ok(()) } #[tokio::test] -async fn delete_records_batch_with_matching_role_tag_access_succeeds() -> anyhow::Result<()> { +async fn delete_records_batch_deletes_authorized_differently_tagged_records() -> anyhow::Result<()> { let client = get_funded_test_client().await?; let trail_id = client - .create_test_trail_with_tags(Data::text("batch-delete-tagged-allow"), ["finance"]) + .create_test_trail_with_tags(Data::text("batch-delete-tagged-allow"), ["finance", "legal"]) .await?; let records = client.trail(trail_id).records(); @@ -1178,16 +1176,25 @@ async fn delete_records_batch_with_matching_role_tag_access_succeeds() -> anyhow .create_role( trail_id, "TaggedDeleteAll", - [Permission::AddRecord, Permission::DeleteAllRecords], - Some(RoleTags::new(["finance"])), + [ + Permission::AddRecord, + Permission::DeleteRecord, + Permission::DeleteAllRecords, + ], + Some(RoleTags::new(["finance", "legal"])), ) .await?; client .issue_cap(trail_id, "TaggedDeleteAll", CapabilityIssueOptions::default()) .await?; + records.delete(0).build_and_execute(&client).await?; records - .add(Data::text("tagged record"), None, Some("finance".to_string())) + .add(Data::text("finance record"), None, Some("finance".to_string())) + .build_and_execute(&client) + .await?; + records + .add(Data::text("legal record"), None, Some("legal".to_string())) .build_and_execute(&client) .await?; @@ -1196,9 +1203,10 @@ async fn delete_records_batch_with_matching_role_tag_access_succeeds() -> anyhow .build_and_execute(&client) .await? .output; - assert_eq!(deleted, vec![0, 1]); + assert_eq!(deleted, vec![1, 2]); assert_eq!(records.record_count().await?, 0); assert!(records.get(1).await.is_err()); + assert!(records.get(2).await.is_err()); Ok(()) } From 86dd52346ef5713287d7fb7214dcc883c871e0f0 Mon Sep 17 00:00:00 2001 From: Yasir Date: Tue, 5 May 2026 15:14:20 +0300 Subject: [PATCH 178/189] Refactor conditional formatting for record tag validation in batch delete --- audit-trail-move/sources/audit_trail.move | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/audit-trail-move/sources/audit_trail.move b/audit-trail-move/sources/audit_trail.move index 9e24f983..8e9006ae 100644 --- a/audit-trail-move/sources/audit_trail.move +++ b/audit-trail-move/sources/audit_trail.move @@ -435,11 +435,13 @@ public fun delete_records_batch( continue }; - if (!record_tag_allowed( - self, - cap, - record::tag(linked_table::borrow(&self.records, sequence_number)), - )) { + if ( + !record_tag_allowed( + self, + cap, + record::tag(linked_table::borrow(&self.records, sequence_number)), + ) + ) { continue }; let record = linked_table::remove(&mut self.records, sequence_number); From 343470d83409b68f2073af47a09e9f0afca76d14 Mon Sep 17 00:00:00 2001 From: Yasir Date: Fri, 8 May 2026 10:40:20 +0300 Subject: [PATCH 179/189] Refactor audit-trail record deletion helpers --- audit-trail-move/sources/audit_trail.move | 66 +++++++++++------------ 1 file changed, 31 insertions(+), 35 deletions(-) diff --git a/audit-trail-move/sources/audit_trail.move b/audit-trail-move/sources/audit_trail.move index 8e9006ae..1f40ff0b 100644 --- a/audit-trail-move/sources/audit_trail.move +++ b/audit-trail-move/sources/audit_trail.move @@ -276,7 +276,7 @@ entry fun migrate( self.version = PACKAGE_VERSION; } -fun record_tag_allowed( +fun is_record_tag_allowed( self: &AuditTrail, cap: &Capability, tag: &Option, @@ -290,6 +290,29 @@ fun record_tag_allowed( record_tags::role_allows(&self.roles, cap, requested_tag) } +fun remove_record( + self: &mut AuditTrail, + sequence_number: u64, + deleted_by: address, + timestamp: u64, + trail_id: ID, +) { + let record = linked_table::remove(&mut self.records, sequence_number); + + if (record.tag().is_some()) { + record_tags::decrement_usage_count(&mut self.tags, option::borrow(record.tag())); + }; + + record.destroy(); + + event::emit(RecordDeleted { + trail_id, + sequence_number, + deleted_by, + timestamp, + }); +} + // ===== Record Operations ===== /// Add a record to the trail @@ -315,7 +338,7 @@ public fun add_record( ctx, ); assert!(!locking::is_write_locked(&self.locking_config, clock), ETrailWriteLocked); - assert!(record_tag_allowed(self, cap, &record_tag), ERecordTagNotAllowed); + assert!(is_record_tag_allowed(self, cap, &record_tag), ERecordTagNotAllowed); let caller = ctx.sender(); let timestamp = clock::timestamp_ms(clock); @@ -372,10 +395,10 @@ public fun delete_record( ); assert!(linked_table::contains(&self.records, sequence_number), ERecordNotFound); assert!( - record_tag_allowed( + is_record_tag_allowed( self, cap, - record::tag(linked_table::borrow(&self.records, sequence_number)), + self.records.borrow(sequence_number).tag(), ), ERecordTagNotAllowed, ); @@ -385,18 +408,7 @@ public fun delete_record( let timestamp = clock::timestamp_ms(clock); let trail_id = self.id(); - let record = linked_table::remove(&mut self.records, sequence_number); - if (record::tag(&record).is_some()) { - record_tags::decrement_usage_count(&mut self.tags, option::borrow(record::tag(&record))); - }; - record::destroy(record); - - event::emit(RecordDeleted { - trail_id, - sequence_number, - deleted_by: caller, - timestamp, - }); + self.remove_record(sequence_number, caller, timestamp, trail_id); } /// Delete up to `limit` records from the front of the trail. @@ -436,31 +448,15 @@ public fun delete_records_batch( }; if ( - !record_tag_allowed( + !is_record_tag_allowed( self, cap, - record::tag(linked_table::borrow(&self.records, sequence_number)), + self.records.borrow(sequence_number).tag(), ) ) { continue }; - let record = linked_table::remove(&mut self.records, sequence_number); - - if (record::tag(&record).is_some()) { - record_tags::decrement_usage_count( - &mut self.tags, - option::borrow(record::tag(&record)), - ); - }; - - record.destroy(); - - event::emit(RecordDeleted { - trail_id, - sequence_number, - deleted_by: caller, - timestamp, - }); + self.remove_record(sequence_number, caller, timestamp, trail_id); vector::push_back(&mut deleted_sequence_numbers, sequence_number); deleted = deleted + 1; From c2afb5c907c478948ecf0e8cc01563e0aeb12c4a Mon Sep 17 00:00:00 2001 From: chrisgitiota Date: Tue, 12 May 2026 12:58:36 +0200 Subject: [PATCH 180/189] Fix missing secret-storage dep due to latest upstream branch merges --- Cargo.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/Cargo.toml b/Cargo.toml index ac1ea57f..4c6ba820 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ iota_interaction = { git = "https://github.com/iotaledger/product-core.git", bra iota_interaction_rust = { git = "https://github.com/iotaledger/product-core.git", branch = "feat/tf-compoenents-dev", default-features = false, package = "iota_interaction_rust" } iota_interaction_ts = { git = "https://github.com/iotaledger/product-core.git", branch = "feat/tf-compoenents-dev", default-features = false, package = "iota_interaction_ts" } product_common = { git = "https://github.com/iotaledger/product-core.git", branch = "feat/tf-compoenents-dev", default-features = false, package = "product_common" } +secret-storage = { git = "https://github.com/iotaledger/secret-storage.git", tag = "v0.3.0", default-features = false } serde = { version = "1.0", default-features = false, features = ["alloc", "derive"] } serde-aux = { version = "4.7.0", default-features = false } serde_json = { version = "1.0", default-features = false } From adaf45a4722c9b19eec590f38e61a5d9b1b0e3c4 Mon Sep 17 00:00:00 2001 From: Yasir Date: Thu, 14 May 2026 10:47:52 +0300 Subject: [PATCH 181/189] feat: Add migration permission to admin permissions and corresponding test --- audit-trail-move/sources/permission.move | 1 + audit-trail-move/tests/permission_tests.move | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/audit-trail-move/sources/permission.move b/audit-trail-move/sources/permission.move index b16a984b..82e37767 100644 --- a/audit-trail-move/sources/permission.move +++ b/audit-trail-move/sources/permission.move @@ -94,6 +94,7 @@ public fun admin_permissions(): VecSet { perms.insert(add_roles()); perms.insert(update_roles()); perms.insert(delete_roles()); + perms.insert(migrate_audit_trail()); perms } diff --git a/audit-trail-move/tests/permission_tests.move b/audit-trail-move/tests/permission_tests.move index 67ae90c6..f8316503 100644 --- a/audit-trail-move/tests/permission_tests.move +++ b/audit-trail-move/tests/permission_tests.move @@ -116,6 +116,13 @@ fun test_admin_permissions_include_tag_management() { assert!(permission::has_permission(&perms, &permission::delete_record_tags()), 1); } +#[test] +fun test_admin_permissions_include_migration() { + let perms = permission::admin_permissions(); + + assert!(permission::has_permission(&perms, &permission::migrate_audit_trail()), 0); +} + #[test] #[expected_failure(abort_code = vec_set::EKeyAlreadyExists)] fun test_from_vec_duplicate_permission() { From 245785352588ece2151daf4878044fb38db28b52 Mon Sep 17 00:00:00 2001 From: Yasir Date: Mon, 18 May 2026 10:51:19 +0300 Subject: [PATCH 182/189] feat: Implement count-based locking for record deletion and update related documentation --- audit-trail-move/sources/audit_trail.move | 49 ++++++++++-- audit-trail-move/sources/locking.move | 55 +++----------- audit-trail-move/tests/locking_tests.move | 83 +++++++++++++++++++++ audit-trail-rs/README.md | 5 +- audit-trail-rs/src/core/locking/mod.rs | 6 ++ audit-trail-rs/src/core/types/locking.rs | 10 ++- bindings/wasm/audit_trail_wasm/src/types.rs | 3 +- examples/audit-trail/README.md | 4 +- 8 files changed, 157 insertions(+), 58 deletions(-) diff --git a/audit-trail-move/sources/audit_trail.move b/audit-trail-move/sources/audit_trail.move index 1f40ff0b..a07453e6 100644 --- a/audit-trail-move/sources/audit_trail.move +++ b/audit-trail-move/sources/audit_trail.move @@ -313,6 +313,30 @@ fun remove_record( }); } +/// Returns true if the record is within the last `count` records currently +/// present in linked-table order. +fun is_record_in_last_current_records( + records: &LinkedTable>, + sequence_number: u64, + count: u64, +): bool { + let mut remaining = count; + let mut current = *linked_table::back(records); + + while (remaining > 0 && current.is_some()) { + let current_sequence_number = current.destroy_some(); + + if (current_sequence_number == sequence_number) { + return true + }; + + current = *linked_table::prev(records, current_sequence_number); + remaining = remaining - 1; + }; + + false +} + // ===== Record Operations ===== /// Add a record to the trail @@ -515,6 +539,9 @@ public fun delete_audit_trail( // ===== Locking ===== /// Check if a record is locked based on the trail's locking configuration. +/// +/// Count-based windows lock the last N records currently present in the trail, +/// using the linked-table order after any deletions. /// Aborts with ERecordNotFound if the record doesn't exist. public fun is_record_locked( self: &AuditTrail, @@ -525,14 +552,22 @@ public fun is_record_locked( let record = linked_table::borrow(&self.records, sequence_number); let current_time = clock::timestamp_ms(clock); + let window = locking::delete_record_window(&self.locking_config); - locking::is_delete_record_locked( - &self.locking_config, - sequence_number, - record::added_at(record), - self.sequence_number, - current_time, - ) + if (locking::is_time_locked(window, record::added_at(record), current_time)) { + return true + }; + + let count = locking::count_window(window); + if (count.is_some()) { + return is_record_in_last_current_records( + &self.records, + sequence_number, + count.destroy_some(), + ) + }; + + false } /// Update the locking configuration. Requires `UpdateLockingConfig` permission. diff --git a/audit-trail-move/sources/locking.move b/audit-trail-move/sources/locking.move index 0ff838f5..2d33be62 100644 --- a/audit-trail-move/sources/locking.move +++ b/audit-trail-move/sources/locking.move @@ -12,10 +12,11 @@ use tf_components::timelock::{Self, TimeLock}; /// UntilDestroyed cannot be used for trail deletion protection. const EUntilDestroyedNotSupportedForDeleteTrail: u64 = 0; -/// Defines a locking window (time XOR count based, or none) +/// Defines a delete-record locking window (time-based, count-based, or none). public enum LockingWindow has copy, drop, store { None, TimeBased { seconds: u64 }, + /// Locks the last `count` records currently present in trail order. CountBased { count: u64 }, } @@ -41,7 +42,10 @@ public fun window_time_based(seconds: u64): LockingWindow { LockingWindow::TimeBased { seconds } } -/// Create a count-based locking window +/// Create a count-based locking window. +/// +/// The trail locks the last `count` records currently present in linked-table +/// order. Deletions can move older records into the protected window. public fun window_count_based(count: u64): LockingWindow { LockingWindow::CountBased { count } } @@ -137,7 +141,11 @@ public(package) fun set_config(config: &mut LockingConfig, new_config: LockingCo /// Check if a record is locked based on time window. /// Returns true if the record was created within the time window. -fun is_time_locked(window: &LockingWindow, record_timestamp: u64, current_time: u64): bool { +public(package) fun is_time_locked( + window: &LockingWindow, + record_timestamp: u64, + current_time: u64, +): bool { match (window) { LockingWindow::TimeBased { seconds } => { let time_window_ms = (*seconds) * 1000; @@ -148,49 +156,8 @@ fun is_time_locked(window: &LockingWindow, record_timestamp: u64, current_time: } } -/// Check if a record is locked based on count window. -/// Returns true if the record is among the last N records. -fun is_count_locked(window: &LockingWindow, sequence_number: u64, total_records: u64): bool { - match (window) { - LockingWindow::CountBased { count } => { - let records_after = total_records - sequence_number - 1; - records_after < *count - }, - _ => false, - } -} - -/// Check if a record is locked by a window (either by time or count). -fun is_window_locked( - window: &LockingWindow, - sequence_number: u64, - record_timestamp: u64, - total_records: u64, - current_time: u64, -): bool { - is_time_locked(window, record_timestamp, current_time) - || is_count_locked(window, sequence_number, total_records) -} - // ===== Locking Logic (LockingConfig) ===== -/// Check if a record is locked for deletion. -public fun is_delete_record_locked( - config: &LockingConfig, - sequence_number: u64, - record_timestamp: u64, - total_records: u64, - current_time: u64, -): bool { - is_window_locked( - &config.delete_record_window, - sequence_number, - record_timestamp, - total_records, - current_time, - ) -} - /// Check if trail deletion is currently locked. public fun is_delete_trail_locked(config: &LockingConfig, clock: &Clock): bool { timelock::is_timelocked(delete_trail_lock(config), clock) diff --git a/audit-trail-move/tests/locking_tests.move b/audit-trail-move/tests/locking_tests.move index 57745d3d..80c67c6d 100644 --- a/audit-trail-move/tests/locking_tests.move +++ b/audit-trail-move/tests/locking_tests.move @@ -862,6 +862,89 @@ fun test_count_based_locking_old_record_can_delete() { ts::end(scenario); } +#[test] +fun test_count_based_locking_uses_current_records_after_tail_deletion() { + let admin = @0xAD; + let mut scenario = ts::begin(admin); + + { + let locking_config = locking::new( + locking::window_none(), + timelock::none(), + timelock::none(), + ); + let (admin_cap, _) = setup_test_audit_trail( + &mut scenario, + locking_config, + std::option::none(), + ); + transfer::public_transfer(admin_cap, admin); + }; + + ts::next_tx(&mut scenario, admin); + { + let (admin_cap, mut trail, mut clock) = fetch_capability_trail_and_clock(&mut scenario); + let record_lock_admin_role = string::utf8(b"RecordLockAdmin"); + let record_lock_admin_perms = permission::from_vec(vector[ + permission::add_record(), + permission::delete_record(), + permission::update_locking_config_for_delete_record(), + ]); + + trail + .access_mut() + .create_role( + &admin_cap, + record_lock_admin_role, + record_lock_admin_perms, + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + let record_lock_admin_cap = test_utils::new_capability_without_restrictions( + trail.access_mut(), + &admin_cap, + &string::utf8(b"RecordLockAdmin"), + &clock, + ts::ctx(&mut scenario), + ); + + clock.set_for_testing(initial_time_for_testing() + 1000); + + let mut i = 0u64; + while (i < 5) { + trail.add_record( + &record_lock_admin_cap, + record::new_text(string::utf8(b"Record")), + std::option::none(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + i = i + 1; + }; + + trail.delete_record(&record_lock_admin_cap, 4, &clock, ts::ctx(&mut scenario)); + trail.update_delete_record_window( + &record_lock_admin_cap, + locking::window_count_based(2), + &clock, + ts::ctx(&mut scenario), + ); + + assert!(!trail.has_record(4), 0); + assert!(!trail.is_record_locked(1, &clock), 1); + assert!(trail.is_record_locked(2, &clock), 2); + assert!(trail.is_record_locked(3, &clock), 3); + + record_lock_admin_cap.destroy_for_testing(); + cleanup_capability_trail_and_clock(&scenario, admin_cap, trail, clock); + }; + + ts::end(scenario); +} + #[test] fun test_time_based_locking_still_locked_before_expiry() { let admin = @0xAD; diff --git a/audit-trail-rs/README.md b/audit-trail-rs/README.md index 07f59ed8..e967928d 100644 --- a/audit-trail-rs/README.md +++ b/audit-trail-rs/README.md @@ -175,6 +175,8 @@ controls three independent behaviors: An audit trail can be deleted only after its records are removed and the delete-trail lock allows deletion. Records can be deleted individually with `TrailRecords::delete()` or in batches with `TrailRecords::delete_records_batch()`. +For count-based delete windows, the Move package protects the last N records currently present in trail order. Deletions +can move older records into that protected window, and large count values increase delete gas linearly. #### Updating Locking Rules @@ -232,7 +234,8 @@ The trail deletion process does not remove records automatically. The trail must tag. - `Role Tags`: Optional role-scoped tag restrictions. They narrow which tagged records a role may operate on. - `Locking Config`: The active locking rules for record deletion, trail deletion, and record writes. -- `Delete Record Window`: A locking rule that controls when individual records can be deleted. +- `Delete Record Window`: A locking rule that controls when individual records can be deleted. Count-based windows protect + the last N records currently present in trail order. - `Delete Trail Lock`: A time lock that controls when the entire trail can be deleted. - `Write Lock`: A time lock that controls when new records can be added. - `Immutable Metadata`: Optional metadata stored at creation time and never updated after the trail is created. diff --git a/audit-trail-rs/src/core/locking/mod.rs b/audit-trail-rs/src/core/locking/mod.rs index 3e8cde62..d6772394 100644 --- a/audit-trail-rs/src/core/locking/mod.rs +++ b/audit-trail-rs/src/core/locking/mod.rs @@ -65,6 +65,9 @@ impl<'a, C> TrailLocking<'a, C> { } /// Updates only the delete-record window. + /// + /// Count-based windows protect the last N records currently present in trail + /// order. Large count values increase delete gas linearly. pub fn update_delete_record_window(&self, window: LockingWindow) -> TransactionBuilder where C: AuditTrailFull + CoreClient, @@ -111,6 +114,9 @@ impl<'a, C> TrailLocking<'a, C> { /// Returns `true` when the given record is currently locked against deletion. /// + /// For count-based windows, the check uses the current on-chain record order + /// after deletions. + /// /// # Errors /// /// Returns an error if the lock state cannot be computed from the current on-chain state. diff --git a/audit-trail-rs/src/core/types/locking.rs b/audit-trail-rs/src/core/types/locking.rs index eb03205a..b083a912 100644 --- a/audit-trail-rs/src/core/types/locking.rs +++ b/audit-trail-rs/src/core/types/locking.rs @@ -115,7 +115,7 @@ impl TimeLock { } } -/// Defines a locking window (none, time based, or count based). +/// Defines a delete-record locking window. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] pub enum LockingWindow { /// No delete window is enforced. @@ -126,9 +126,13 @@ pub enum LockingWindow { /// Window size in seconds. seconds: u64, }, - /// Records may be deleted only within the first `count` subsequent records. + /// Locks the last `count` records currently present in trail order. + /// + /// Deletions can move older records into this protected window. The on-chain + /// check walks backward from the current tail, so delete gas scales linearly + /// with `count`. CountBased { - /// Number of subsequent records after which deletion is no longer allowed. + /// Number of current tail records protected from deletion. count: u64, }, } diff --git a/bindings/wasm/audit_trail_wasm/src/types.rs b/bindings/wasm/audit_trail_wasm/src/types.rs index bf3fbeb3..a961704f 100644 --- a/bindings/wasm/audit_trail_wasm/src/types.rs +++ b/bindings/wasm/audit_trail_wasm/src/types.rs @@ -927,7 +927,8 @@ impl WasmLockingWindow { Self(LockingWindow::TimeBased { seconds }) } - /// Creates a count-based delete window. + /// Creates a count-based delete window that protects the last `count` + /// records currently present in trail order. #[wasm_bindgen(js_name = withCountBased)] pub fn with_count_based(count: u64) -> Self { Self(LockingWindow::CountBased { count }) diff --git a/examples/audit-trail/README.md b/examples/audit-trail/README.md index 840db4ef..d9d83127 100644 --- a/examples/audit-trail/README.md +++ b/examples/audit-trail/README.md @@ -129,7 +129,7 @@ When issuing a capability, `CapabilityIssueOptions` allows restricting its use: Trails support three independent lock dimensions: - **Write lock** (`TimeLock`): Prevents new records from being added -- **Delete-record window** (`LockingWindow`): Time-based or count-based window during which a record can be deleted after creation +- **Delete-record window** (`LockingWindow`): Time-based window or count-based protection for the last N records currently present in trail order - **Delete-trail lock** (`TimeLock`): Prevents the trail itself from being destroyed `TimeLock` variants: `None`, `UnlockAt(u32)`, `UnlockAtMs(u64)`, `UntilDestroyed`, `Infinite`. @@ -147,7 +147,7 @@ Trails support three independent lock dimensions: ### Compliance Use Cases - **Locked write windows** to prevent retroactive record insertion -- **Delete-record windows** to allow corrections within a time limit, then freeze +- **Delete-record windows** to allow corrections within a time limit or retain the latest N current records - **Role separation** to enforce least-privilege access (auditors can read, operators can write) - **Bound capabilities** to tie a capability to a specific operator address From 0e6e7b812e5c985f1f35a0ca9a369b8e512f877f Mon Sep 17 00:00:00 2001 From: Yasir Date: Mon, 18 May 2026 11:06:53 +0300 Subject: [PATCH 183/189] fix: Correct indentation in is_record_locked function for improved readability --- audit-trail-move/sources/audit_trail.move | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/audit-trail-move/sources/audit_trail.move b/audit-trail-move/sources/audit_trail.move index a07453e6..e7df790e 100644 --- a/audit-trail-move/sources/audit_trail.move +++ b/audit-trail-move/sources/audit_trail.move @@ -561,10 +561,10 @@ public fun is_record_locked( let count = locking::count_window(window); if (count.is_some()) { return is_record_in_last_current_records( - &self.records, - sequence_number, - count.destroy_some(), - ) + &self.records, + sequence_number, + count.destroy_some(), + ) }; false From 6bcc9906a6a4f6fab02d4f011868072a531b0395 Mon Sep 17 00:00:00 2001 From: Yasir Date: Mon, 18 May 2026 11:14:41 +0300 Subject: [PATCH 184/189] Add product selector to docs upload workflow --- .github/workflows/upload-docs.yml | 40 +++++++++++++++++++++++++------ 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/.github/workflows/upload-docs.yml b/.github/workflows/upload-docs.yml index 5c2dda6e..c1385916 100644 --- a/.github/workflows/upload-docs.yml +++ b/.github/workflows/upload-docs.yml @@ -11,6 +11,13 @@ on: ref: description: "Optional git ref to checkout before building docs" required: false + product: + description: "Which product docs to publish" + required: true + type: choice + options: + - notarization + - audit-trail env: GH_TOKEN: ${{ github.token }} @@ -20,6 +27,7 @@ permissions: jobs: build-wasm-notarization: + if: ${{ github.event_name == 'release' || github.event.inputs.product == 'notarization' }} uses: "./.github/workflows/shared-build-wasm.yml" with: run-unit-tests: false @@ -27,6 +35,7 @@ jobs: output-artifact-name: notarization-docs build-wasm-audit-trail: + if: ${{ github.event_name == 'release' || github.event.inputs.product == 'audit-trail' }} uses: "./.github/workflows/shared-build-wasm.yml" with: run-unit-tests: false @@ -35,18 +44,14 @@ jobs: wasm-package-dir: bindings/wasm/audit_trail_wasm wasm-crate-name: audit_trail_wasm - upload-docs: + upload-notarization-docs: runs-on: ubuntu-latest - needs: [build-wasm-notarization, build-wasm-audit-trail] + needs: [build-wasm-notarization] steps: - uses: actions/download-artifact@v4 with: name: notarization-docs path: notarization-docs - - uses: actions/download-artifact@v4 - with: - name: audit-trail-docs - path: audit-trail-docs - name: Get release version id: get_release_version run: | @@ -60,7 +65,6 @@ jobs: - name: Compress generated docs run: | tar czvf wasm.tar.gz notarization-docs/docs/* - tar czvf audit-trail-wasm.tar.gz audit-trail-docs/docs/* - name: Upload notarization docs to AWS S3 env: @@ -70,6 +74,28 @@ jobs: run: | aws s3 cp wasm.tar.gz s3://files.iota.org/iota-wiki/iota-notarization/${{ steps.get_release_version.outputs.VERSION }}/ --acl public-read + upload-audit-trail-docs: + runs-on: ubuntu-latest + needs: [build-wasm-audit-trail] + steps: + - uses: actions/download-artifact@v4 + with: + name: audit-trail-docs + path: audit-trail-docs + - name: Get release version + id: get_release_version + run: | + if [ "${{ github.event_name }}" = "release" ]; then + INPUT_VERSION="${{ github.ref }}" + else + INPUT_VERSION="${{ github.event.inputs.version }}" + fi + VERSION=$(echo $INPUT_VERSION | sed -e 's/.*v\([0-9]*\.[0-9]*\).*/\1/') + echo VERSION=$VERSION >> $GITHUB_OUTPUT + - name: Compress generated docs + run: | + tar czvf audit-trail-wasm.tar.gz audit-trail-docs/docs/* + - name: Upload audit trail docs to AWS S3 env: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID_IOTA_WIKI }} From 97c1cb850c806bba4eaa28cc9b92b828365c8fa9 Mon Sep 17 00:00:00 2001 From: chrisgitiota Date: Mon, 18 May 2026 11:20:08 +0200 Subject: [PATCH 185/189] Fix aws upload URL for audit-trail-wasm --- .github/workflows/upload-docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/upload-docs.yml b/.github/workflows/upload-docs.yml index 5c2dda6e..17d4f366 100644 --- a/.github/workflows/upload-docs.yml +++ b/.github/workflows/upload-docs.yml @@ -76,4 +76,4 @@ jobs: AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY_IOTA_WIKI }} AWS_DEFAULT_REGION: "eu-central-1" run: | - aws s3 cp audit-trail-wasm.tar.gz s3://files.iota.org/iota-wiki/iota-audit-trail/${{ steps.get_release_version.outputs.VERSION }}/ --acl public-read + aws s3 cp audit-trail-wasm.tar.gz s3://files.iota.org/iota-wiki/iota-notarization/${{ steps.get_release_version.outputs.VERSION }}/ --acl public-read From 901dae0e82e27056a27f8f70f54408a441b867ac Mon Sep 17 00:00:00 2001 From: Christof Gerritsma Date: Thu, 21 May 2026 10:16:19 +0200 Subject: [PATCH 186/189] Rewrite of `delete_records_batch` and `is_record_locked` functions (#263) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rewrite of the `delete_records_batch()` and `is_record_locked()` functions to only evaluate against the existing records once when the transaction begins (has been evaluated for each deleted record before) - `window_count_based(0)` is now forbidden in favor of using `window_none()` instead to prevent silently misconfigured trails. - Client-relevant asserts in Move `locking` module are validated in the AT Rust libraries public surface now The Move locking module has two client-relevant asserts, now being also validated in the Rust crate: 1. ECountWindowMustBePositive — window_count_based(0) is rejected. 2. EUntilDestroyedNotSupportedForDeleteTrail — TimeLock::UntilDestroyed cannot be used as the trail-level delete lock (but it is valid for write_lock). The tf_components::timelock module also asserts EPastTimestamp on unlock_at/unlock_at_ms when the timestamp is in the past. This is not mirrored in the Rust crate because it depends on the on-chain clock at execution time, so a client-side check would either be redundant (if the user picks a far-future timestamp) or wrong (if the transaction is built well before submission and a borderline timestamp lapses). --------- Co-authored-by: Yasir --- audit-trail-move/Move.lock | 8 +- audit-trail-move/sources/audit_trail.move | 169 ++++++++++++++---- audit-trail-move/sources/locking.move | 13 +- audit-trail-move/tests/capability_tests.move | 12 +- .../tests/create_audit_trail_tests.move | 12 +- audit-trail-move/tests/metadata_tests.move | 6 +- audit-trail-move/tests/role_tests.move | 22 +-- audit-trail-rs/src/client/full_client.rs | 4 +- audit-trail-rs/src/core/builder.rs | 17 +- audit-trail-rs/src/core/locking/mod.rs | 56 ++++-- audit-trail-rs/src/core/records/mod.rs | 25 ++- .../src/core/records/transactions.rs | 9 +- audit-trail-rs/src/core/types/locking.rs | 58 +++++- audit-trail-rs/tests/e2e/client.rs | 2 +- audit-trail-rs/tests/e2e/locking.rs | 26 +-- audit-trail-rs/tests/e2e/records.rs | 8 +- audit-trail-rs/tests/e2e/trail.rs | 24 +-- bindings/wasm/audit_trail_wasm/src/builder.rs | 7 +- .../src/trail_handle/locking.rs | 3 + examples/audit-trail/01_create_audit_trail.rs | 2 +- .../audit-trail/02_add_and_read_records.rs | 2 +- examples/audit-trail/03_update_metadata.rs | 2 +- examples/audit-trail/04_configure_locking.rs | 6 +- examples/audit-trail/05_manage_access.rs | 2 +- examples/audit-trail/06_delete_records.rs | 2 +- .../07_access_read_only_methods.rs | 2 +- examples/audit-trail/08_delete_audit_trail.rs | 2 +- .../audit-trail/advanced/09_tagged_records.rs | 2 +- .../advanced/10_capability_constraints.rs | 2 +- .../advanced/11_manage_record_tags.rs | 2 +- .../real-world/01_customs_clearance.rs | 2 +- .../real-world/02_clinical_trial.rs | 4 +- .../real-world/03_digital_product_passport.rs | 2 +- 33 files changed, 374 insertions(+), 141 deletions(-) diff --git a/audit-trail-move/Move.lock b/audit-trail-move/Move.lock index 9504e416..0d29a698 100644 --- a/audit-trail-move/Move.lock +++ b/audit-trail-move/Move.lock @@ -54,16 +54,16 @@ dependencies = [ ] [move.toolchain-version] -compiler-version = "1.22.1-rc" +compiler-version = "1.20.1" edition = "2024.beta" flavor = "iota" [env] [env.localnet] -chain-id = "c8ba4765" -original-published-id = "0xf0a9009527072c34f56f34d8cf96218c3a2e008f30c568c55c08df01be19f51d" -latest-published-id = "0xf0a9009527072c34f56f34d8cf96218c3a2e008f30c568c55c08df01be19f51d" +chain-id = "4c9c65c9" +original-published-id = "0x567c1e6c76db3b47826019f15818c747e0a2588d428e00c13863bcf683ec5bbe" +latest-published-id = "0x567c1e6c76db3b47826019f15818c747e0a2588d428e00c13863bcf683ec5bbe" published-version = "1" [env.testnet] diff --git a/audit-trail-move/sources/audit_trail.move b/audit-trail-move/sources/audit_trail.move index e7df790e..9a7b23c7 100644 --- a/audit-trail-move/sources/audit_trail.move +++ b/audit-trail-move/sources/audit_trail.move @@ -26,6 +26,7 @@ use std::string::String; use tf_components::{capability::Capability, role_map::{Self, RoleMap}, timelock::TimeLock}; // ===== Errors ===== + #[error] const ERecordNotFound: vector = b"Record not found at the given sequence number"; #[error] @@ -50,7 +51,9 @@ const ERecordTagAlreadyDefined: vector = #[error] const ERecordTagInUse: vector = b"The requested tag cannot be removed because it is already used by an existing record or role"; + // ===== Constants ===== + const INITIAL_ADMIN_ROLE_NAME: vector = b"Admin"; // Package version, incremented when the package is updated @@ -313,28 +316,75 @@ fun remove_record( }); } -/// Returns true if the record is within the last `count` records currently -/// present in linked-table order. -fun is_record_in_last_current_records( +/// Returns the lowest sequence_number within the last `count` records, +/// given that sequence_numbers decrease monotonically, walking from +/// the tail toward the head. Returns 0 if the table is empty or `count` is 0. +fun get_lowest_sequence_number_in_count_window( records: &LinkedTable>, - sequence_number: u64, count: u64, -): bool { - let mut remaining = count; +): u64 { + if (count == 0) { + return 0 + }; + let mut current = *linked_table::back(records); + let mut remaining = count - 1; + let mut lowest = 0; - while (remaining > 0 && current.is_some()) { + while (current.is_some()) { let current_sequence_number = current.destroy_some(); + lowest = current_sequence_number; - if (current_sequence_number == sequence_number) { - return true + if (remaining == 0) { + break }; current = *linked_table::prev(records, current_sequence_number); remaining = remaining - 1; }; - false + lowest +} + +/// Precomputes the count-window threshold for `lock_window`. +/// +/// Returns `Some(lowest_sequence_number_in_window)` when `lock_window` is a +/// count-based window with a positive count, or `None` otherwise. A record +/// with `sequence_number >= threshold` is count-locked. +fun compute_count_lock_threshold( + records: &LinkedTable>, + lock_window: &LockingWindow, +): Option { + let count_opt = lock_window.count_window(); + if (count_opt.is_some() && *count_opt.borrow() > 0) { + option::some(get_lowest_sequence_number_in_count_window(records, count_opt.destroy_some())) + } else { + option::none() + } +} + +// Returns true if the record at `sequence_number` is locked by the +// `lock_window`. Uses the precomputed `count_lock_threshold` to evaluate +// count based windows and the `current_time` values to evaluate time +// based windows. +// +// Aborts if `sequence_number` is not in `records`. +fun is_record_locked_in_window( + records: &LinkedTable>, + sequence_number: u64, + lock_window: &LockingWindow, + count_lock_threshold: &Option, + current_time: u64, +): bool { + // This is the shared lock-evaluation core used by `is_record_locked` and + // `delete_records_batch`. Add new lock kinds here so both call sites pick + // them up automatically. + if (count_lock_threshold.is_some() && sequence_number >= *count_lock_threshold.borrow()) { + return true + }; + + let record = records.borrow(sequence_number); + lock_window.is_time_locked(record.added_at(), current_time) } // ===== Record Operations ===== @@ -437,8 +487,55 @@ public fun delete_record( /// Delete up to `limit` records from the front of the trail. /// -/// Requires `DeleteAllRecords` permission. Locked records are skipped. -/// Returns the sequence numbers deleted in this batch, in deletion order. +/// Requires `DeleteAllRecords` permission. Locked records and records whose +/// tag is not permitted by `cap` are silently skipped. Returns the sequence +/// numbers actually deleted, in deletion order. The returned vector may be +/// shorter than `limit` (or empty) if records are skipped or the trail runs +/// out of records before `limit` is reached. +/// +/// Locking semantics +/// ----------------- +/// The set of locked records is fixed at the start of the transaction: +/// +/// * If a count-based `LockingWindow` is configured, the protected window is +/// the last `count` records present *when this call begins*. Records that +/// this same call deletes do not have an impact onto other records. +/// The oldest protected record in the count-based `LockingWindow` is +/// determined up front and its sequence_number is reused as delete criteria +/// for every other candidate record. Concurrent transactions that add +/// records or update the locking configuration are observed by *subsequent* +/// transactions only. +/// * Time-based locks are evaluated against the clock timestamp captured at +/// the start of the call, so a record's lock status is also stable for the +/// duration of the batch. +/// +/// Equivalence with `delete_record` +/// -------------------------------- +/// Running `delete_records_batch(limit)` produces the same final trail state as invoking +/// `delete_record` once for every sequence number this batch would delete, +/// as long as the locking configuration is not mutated and no new records are added +/// to the trail between the batch calls. +/// This holds because the count-window's lower bound is monotonic under deletion: +/// in-window records are locked and therefore never deleted, so deleting any +/// out-of-window record leaves the window's contents unchanged. +/// +/// Caveats +/// ------- +/// * **Partial progress.** The function always returns success even when +/// fewer than `limit` records are deleted. Callers that need to detect +/// "nothing left to delete" should inspect the length of the returned +/// vector — an empty vector means every front-to-back candidate was either +/// locked or tag-filtered out. +/// * **Tag filtering is silent.** Records whose tag is not in `cap`'s allowed +/// set are skipped without error. A capability with a narrow tag scope can +/// therefore make the batch appear to "stop early" while locked-and-disallowed +/// records still exist further back. +/// * **Gas and object-size limits.** The call walks the trail from the front +/// and deletes inline. Large `limit` values can exhaust the per-transaction +/// gas budget or hit object-mutation limits. Prefer lower `limit` values +/// resulting in modest batch sizes and repeat the call. +/// * **Front-to-back order is fixed.** There is no way to target specific +/// sequence numbers through this API — use `delete_record` for that. public fun delete_records_batch( self: &mut AuditTrail, cap: &Capability, @@ -461,13 +558,26 @@ public fun delete_records_batch( let caller = ctx.sender(); let timestamp = clock.timestamp_ms(); let trail_id = self.id(); - let mut current = *linked_table::front(&self.records); + + let lock_window = *self.locking_config.delete_record_window(); + + // Precompute the count-window threshold once. Iteration deletes from the + // front while the back is preserved, so the threshold stays valid. + let count_lock_threshold = compute_count_lock_threshold(&self.records, &lock_window); + + let mut current = *self.records.front(); while (deleted < limit && current.is_some()) { let sequence_number = current.destroy_some(); - current = *linked_table::next(&self.records, sequence_number); + current = *self.records.next(sequence_number); - if (self.is_record_locked(sequence_number, clock)) { + if (is_record_locked_in_window( + &self.records, + sequence_number, + &lock_window, + &count_lock_threshold, + timestamp, + )) { continue }; @@ -481,7 +591,7 @@ public fun delete_records_batch( continue }; self.remove_record(sequence_number, caller, timestamp, trail_id); - vector::push_back(&mut deleted_sequence_numbers, sequence_number); + deleted_sequence_numbers.push_back(sequence_number); deleted = deleted + 1; }; @@ -550,24 +660,17 @@ public fun is_record_locked( ): bool { assert!(linked_table::contains(&self.records, sequence_number), ERecordNotFound); - let record = linked_table::borrow(&self.records, sequence_number); - let current_time = clock::timestamp_ms(clock); - let window = locking::delete_record_window(&self.locking_config); - - if (locking::is_time_locked(window, record::added_at(record), current_time)) { - return true - }; + let current_time = clock.timestamp_ms(); + let lock_window = self.locking_config.delete_record_window(); + let count_lock_threshold = compute_count_lock_threshold(&self.records, lock_window); - let count = locking::count_window(window); - if (count.is_some()) { - return is_record_in_last_current_records( - &self.records, - sequence_number, - count.destroy_some(), - ) - }; - - false + is_record_locked_in_window( + &self.records, + sequence_number, + lock_window, + &count_lock_threshold, + current_time, + ) } /// Update the locking configuration. Requires `UpdateLockingConfig` permission. diff --git a/audit-trail-move/sources/locking.move b/audit-trail-move/sources/locking.move index 2d33be62..fca578fe 100644 --- a/audit-trail-move/sources/locking.move +++ b/audit-trail-move/sources/locking.move @@ -12,6 +12,9 @@ use tf_components::timelock::{Self, TimeLock}; /// UntilDestroyed cannot be used for trail deletion protection. const EUntilDestroyedNotSupportedForDeleteTrail: u64 = 0; +/// A count-based locking window must protect at least one record. +const ECountWindowMustBePositive: u64 = 1; + /// Defines a delete-record locking window (time-based, count-based, or none). public enum LockingWindow has copy, drop, store { None, @@ -45,8 +48,16 @@ public fun window_time_based(seconds: u64): LockingWindow { /// Create a count-based locking window. /// /// The trail locks the last `count` records currently present in linked-table -/// order. Deletions can move older records into the protected window. +/// order. To express "no deletion lock", use `window_none()` instead of +/// passing `count == 0`. +/// +/// Aborts +/// ------ +/// * `ECountWindowMustBePositive` if `count == 0`. A zero-count window would +/// protect no records and is functionally identical to `window_none()`; +/// rejecting it at construction prevents silently misconfigured trails. public fun window_count_based(count: u64): LockingWindow { + assert!(count > 0, ECountWindowMustBePositive); LockingWindow::CountBased { count } } diff --git a/audit-trail-move/tests/capability_tests.move b/audit-trail-move/tests/capability_tests.move index 6aaad721..8613e6d4 100644 --- a/audit-trail-move/tests/capability_tests.move +++ b/audit-trail-move/tests/capability_tests.move @@ -66,7 +66,7 @@ fun setup_trail_with_record_admin_role(scenario: &mut Scenario, admin_user: addr // Setup: Create audit trail with admin capability let trail_id = { let locking_config = locking::new( - locking::window_count_based(0), + locking::window_none(), timelock::none(), timelock::none(), ); @@ -118,7 +118,7 @@ fun test_new_capability() { let trail_id = { let locking_config = locking::new( - locking::window_count_based(0), + locking::window_none(), timelock::none(), timelock::none(), ); @@ -603,7 +603,7 @@ fun test_revoked_capability_cannot_be_used() { { let locking_config = locking::new( - locking::window_count_based(0), + locking::window_none(), timelock::none(), timelock::none(), ); @@ -694,7 +694,7 @@ fun test_new_capability_for_nonexistent_role() { { let locking_config = locking::new( - locking::window_count_based(0), + locking::window_none(), timelock::none(), timelock::none(), ); @@ -736,7 +736,7 @@ fun test_revoke_capability_permission_denied() { { let locking_config = locking::new( - locking::window_count_based(0), + locking::window_none(), timelock::none(), timelock::none(), ); @@ -834,7 +834,7 @@ fun test_new_capability_permission_denied() { { let locking_config = locking::new( - locking::window_count_based(0), + locking::window_none(), timelock::none(), timelock::none(), ); diff --git a/audit-trail-move/tests/create_audit_trail_tests.move b/audit-trail-move/tests/create_audit_trail_tests.move index 8f6f6c52..77145163 100644 --- a/audit-trail-move/tests/create_audit_trail_tests.move +++ b/audit-trail-move/tests/create_audit_trail_tests.move @@ -26,7 +26,7 @@ fun test_create_without_initial_record() { { let locking_config = locking::new( - locking::window_count_based(0), + locking::window_none(), timelock::none(), timelock::none(), ); @@ -68,7 +68,7 @@ fun test_tag_admin_role_can_manage_available_record_tags() { { let locking_config = locking::new( - locking::window_count_based(0), + locking::window_none(), timelock::none(), timelock::none(), ); @@ -296,7 +296,7 @@ fun test_create_minimal_metadata() { clock.set_for_testing(3000); let locking_config = locking::new( - locking::window_count_based(0), + locking::window_none(), timelock::none(), timelock::none(), ); @@ -379,7 +379,7 @@ fun test_create_multiple_trails() { // Create first trail { let locking_config = locking::new( - locking::window_count_based(0), + locking::window_none(), timelock::none(), timelock::none(), ); @@ -398,7 +398,7 @@ fun test_create_multiple_trails() { // Create second trail { let locking_config = locking::new( - locking::window_count_based(0), + locking::window_none(), timelock::none(), timelock::none(), ); @@ -428,7 +428,7 @@ fun test_create_metadata_admin_role() { // Creator creates the audit trail { let locking_config = locking::new( - locking::window_count_based(0), + locking::window_none(), timelock::none(), timelock::none(), ); diff --git a/audit-trail-move/tests/metadata_tests.move b/audit-trail-move/tests/metadata_tests.move index 62c7a4ea..102792ab 100644 --- a/audit-trail-move/tests/metadata_tests.move +++ b/audit-trail-move/tests/metadata_tests.move @@ -28,7 +28,7 @@ fun test_update_metadata_success() { // Setup: Create audit trail { let locking_config = locking::new( - locking::window_count_based(0), + locking::window_none(), timelock::none(), timelock::none(), ); @@ -151,7 +151,7 @@ fun test_update_metadata_permission_denied() { // Setup { let locking_config = locking::new( - locking::window_count_based(0), + locking::window_none(), timelock::none(), timelock::none(), ); @@ -223,7 +223,7 @@ fun test_update_metadata_revoked_capability() { // Setup: Create audit trail { let locking_config = locking::new( - locking::window_count_based(0), + locking::window_none(), timelock::none(), timelock::none(), ); diff --git a/audit-trail-move/tests/role_tests.move b/audit-trail-move/tests/role_tests.move index a674988a..67984f82 100644 --- a/audit-trail-move/tests/role_tests.move +++ b/audit-trail-move/tests/role_tests.move @@ -32,7 +32,7 @@ fun test_role_based_permission_delegation() { // Step 1: admin_user creates the audit trail let trail_id = { let locking_config = locking::new( - locking::window_count_based(0), + locking::window_none(), timelock::none(), timelock::none(), ); @@ -227,7 +227,7 @@ fun test_create_role_rejects_undefined_record_tags() { { let locking_config = locking::new( - locking::window_count_based(0), + locking::window_none(), timelock::none(), timelock::none(), ); @@ -268,7 +268,7 @@ fun test_delete_role_success() { { let locking_config = locking::new( - locking::window_count_based(0), + locking::window_none(), timelock::none(), timelock::none(), ); @@ -332,7 +332,7 @@ fun test_remove_record_tag_rejects_role_only_usage() { { let locking_config = locking::new( - locking::window_count_based(0), + locking::window_none(), timelock::none(), timelock::none(), ); @@ -385,7 +385,7 @@ fun test_create_role_permission_denied() { // Setup { let locking_config = locking::new( - locking::window_count_based(0), + locking::window_none(), timelock::none(), timelock::none(), ); @@ -463,7 +463,7 @@ fun test_delete_role_permission_denied() { // Setup { let locking_config = locking::new( - locking::window_count_based(0), + locking::window_none(), timelock::none(), timelock::none(), ); @@ -545,7 +545,7 @@ fun test_update_role_permissions_permission_denied() { // Setup { let locking_config = locking::new( - locking::window_count_based(0), + locking::window_none(), timelock::none(), timelock::none(), ); @@ -634,7 +634,7 @@ fun test_get_role_permissions_nonexistent() { { let locking_config = locking::new( - locking::window_count_based(0), + locking::window_none(), timelock::none(), timelock::none(), ); @@ -667,7 +667,7 @@ fun test_update_role_permissions_success() { { let locking_config = locking::new( - locking::window_count_based(0), + locking::window_none(), timelock::none(), timelock::none(), ); @@ -733,7 +733,7 @@ fun test_update_role_permissions_rejects_undefined_record_tags() { { let locking_config = locking::new( - locking::window_count_based(0), + locking::window_none(), timelock::none(), timelock::none(), ); @@ -783,7 +783,7 @@ fun test_update_role_permissions_nonexistent() { { let locking_config = locking::new( - locking::window_count_based(0), + locking::window_none(), timelock::none(), timelock::none(), ); diff --git a/audit-trail-rs/src/client/full_client.rs b/audit-trail-rs/src/client/full_client.rs index 3c77523c..a8ac8c79 100644 --- a/audit-trail-rs/src/client/full_client.rs +++ b/audit-trail-rs/src/client/full_client.rs @@ -22,7 +22,7 @@ //! let created = client //! .create_trail() //! .with_initial_record_parts(Data::text("Initial record"), None, None) -//! .finish() +//! .finish()? //! .with_gas_budget(1_000_000) //! .build_and_execute(client) //! .await?; @@ -53,7 +53,7 @@ //! .create_trail() //! .with_initial_record_parts(Data::text("Initial record"), None, None) //! .with_record_tags(["finance"]) -//! .finish() +//! .finish()? //! .build_and_execute(client) //! .await?; //! diff --git a/audit-trail-rs/src/core/builder.rs b/audit-trail-rs/src/core/builder.rs index 3194b0fe..8ad7425f 100644 --- a/audit-trail-rs/src/core/builder.rs +++ b/audit-trail-rs/src/core/builder.rs @@ -8,8 +8,9 @@ use std::collections::HashSet; use iota_interaction::types::base_types::IotaAddress; use product_common::transaction::transaction_builder::TransactionBuilder; -use super::types::{Data, ImmutableMetadata, InitialRecord, LockingConfig}; +use super::types::{Data, ImmutableMetadata, InitialRecord, LockingConfig, LockingWindow, TimeLock}; use crate::core::create::CreateTrail; +use crate::error::Error; /// Builder for creating an audit trail. /// @@ -104,7 +105,17 @@ impl AuditTrailBuilder { } /// Finalizes the builder and creates the trail-creation transaction builder. - pub fn finish(self) -> TransactionBuilder { - TransactionBuilder::new(CreateTrail::new(self)) + /// + /// Validates the configured [`LockingConfig`] before returning the transaction. Currently this rejects: + /// - [`LockingWindow::CountBased`] with `count == 0` (mirrors the Move `ECountWindowMustBePositive` abort). + /// - [`TimeLock::UntilDestroyed`] used as `delete_trail_lock` (mirrors the Move + /// `EUntilDestroyedNotSupportedForDeleteTrail` abort). `write_lock` may still be `UntilDestroyed`. + /// + /// # Errors + /// + /// Returns [`Error::InvalidArgument`] when the locking configuration is invalid. + pub fn finish(self) -> Result, Error> { + self.locking_config.validate()?; + Ok(TransactionBuilder::new(CreateTrail::new(self))) } } diff --git a/audit-trail-rs/src/core/locking/mod.rs b/audit-trail-rs/src/core/locking/mod.rs index d6772394..18268f8d 100644 --- a/audit-trail-rs/src/core/locking/mod.rs +++ b/audit-trail-rs/src/core/locking/mod.rs @@ -49,52 +49,79 @@ impl<'a, C> TrailLocking<'a, C> { /// Replaces the full locking configuration for the trail. /// /// This overwrites all three locking dimensions at once: record delete window, trail delete lock, and - /// write lock. - pub fn update(&self, config: LockingConfig) -> TransactionBuilder + /// write lock. The supplied [`LockingConfig`] is validated before the transaction is constructed. + /// + /// # Errors + /// + /// Returns [`Error::InvalidArgument`] when `config` contains: + /// * `delete_record_window` using [`LockingWindow::CountBased`] with `count == 0` (mirrors the Move `ECountWindowMustBePositive` abort). + /// * `delete_trail_lock` using [`TimeLock::UntilDestroyed`] (mirrors the Move `EUntilDestroyedNotSupportedForDeleteTrail` abort). + pub fn update(&self, config: LockingConfig) -> Result, Error> where C: AuditTrailFull + CoreClient, S: Signer + OptionalSync, { + config.validate()?; let owner = self.client.sender_address(); - TransactionBuilder::new(UpdateLockingConfig::new( + Ok(TransactionBuilder::new(UpdateLockingConfig::new( self.trail_id, owner, config, self.selected_capability_id, - )) + ))) } /// Updates only the delete-record window. /// - /// Count-based windows protect the last N records currently present in trail - /// order. Large count values increase delete gas linearly. - pub fn update_delete_record_window(&self, window: LockingWindow) -> TransactionBuilder + /// Count-based windows protect the last N records present in trail order at the start of each call that + /// consults the window. `count` must be positive; pass [`LockingWindow::None`] to remove the lock. + /// Large count values increase delete gas linearly because the on-chain check walks backward from the tail + /// to determine the protected window's lower bound. + /// + /// # Errors + /// + /// Returns [`Error::InvalidArgument`] when `window` is [`LockingWindow::CountBased`] with `count == 0` + /// (mirrors the Move `ECountWindowMustBePositive` abort). + pub fn update_delete_record_window( + &self, + window: LockingWindow, + ) -> Result, Error> where C: AuditTrailFull + CoreClient, S: Signer + OptionalSync, { + window.validate()?; let owner = self.client.sender_address(); - TransactionBuilder::new(UpdateDeleteRecordWindow::new( + Ok(TransactionBuilder::new(UpdateDeleteRecordWindow::new( self.trail_id, owner, window, self.selected_capability_id, - )) + ))) } /// Updates only the delete-trail time lock. - pub fn update_delete_trail_lock(&self, lock: TimeLock) -> TransactionBuilder + /// + /// # Errors + /// + /// Returns [`Error::InvalidArgument`] when `lock` is [`TimeLock::UntilDestroyed`] + /// (mirrors the Move `EUntilDestroyedNotSupportedForDeleteTrail` abort). + pub fn update_delete_trail_lock( + &self, + lock: TimeLock, + ) -> Result, Error> where C: AuditTrailFull + CoreClient, S: Signer + OptionalSync, { + lock.validate_as_delete_trail_lock()?; let owner = self.client.sender_address(); - TransactionBuilder::new(UpdateDeleteTrailLock::new( + Ok(TransactionBuilder::new(UpdateDeleteTrailLock::new( self.trail_id, owner, lock, self.selected_capability_id, - )) + ))) } /// Updates only the write lock. @@ -114,8 +141,9 @@ impl<'a, C> TrailLocking<'a, C> { /// Returns `true` when the given record is currently locked against deletion. /// - /// For count-based windows, the check uses the current on-chain record order - /// after deletions. + /// For count-based windows, the check determines the protected window's lower bound by walking back + /// from the current tail at call time; time-based locks are evaluated against the clock timestamp at + /// call time. The result reflects the trail snapshot observed by this read-only call. /// /// # Errors /// diff --git a/audit-trail-rs/src/core/records/mod.rs b/audit-trail-rs/src/core/records/mod.rs index 5e3de837..0a2af145 100644 --- a/audit-trail-rs/src/core/records/mod.rs +++ b/audit-trail-rs/src/core/records/mod.rs @@ -112,8 +112,29 @@ impl<'a, C, D> TrailRecords<'a, C, D> { /// Builds a transaction that deletes up to `limit` records in one operation. /// - /// Batch deletion requires `DeleteAllRecords`, skips locked records and records outside the capability's tag - /// access, and removes up to `limit` eligible records in trail order. + /// Batch deletion requires `DeleteAllRecords`, walks the trail from the front in sequence order, and silently + /// skips records that are either locked or whose tag is not in the capability's allowed set. The returned + /// vector contains the sequence numbers actually deleted in deletion order; it may be shorter than `limit` + /// (or empty) when records are skipped or the trail runs out of records before `limit` is reached. + /// + /// # Locking semantics + /// + /// The set of locked records is fixed at the start of the transaction. For count-based windows, the protected + /// window is the last `count` records present when the call begins — records this same call deletes do not + /// change which other records are protected. Time-based locks are evaluated against the clock timestamp + /// captured at the start of the call. Running `delete_records_batch(limit)` therefore produces the same + /// final trail state as invoking `delete_record` once per deletable sequence number, as long as the locking + /// configuration is not mutated and no new records are added between calls. + /// + /// # Caveats + /// + /// - **Partial progress is not an error.** An empty returned vector means every front-to-back candidate was + /// either locked or tag-filtered out. + /// - **Tag filtering is silent.** A capability with a narrow tag scope can make the batch appear to stop + /// early while locked-and-disallowed records still exist further back. + /// - **Gas and object-size limits.** The call walks and mutates inline; prefer modest `limit` values and + /// repeat the call rather than passing a single large `limit`. + /// - **Order is fixed.** Use [`Self::delete`] to target specific sequence numbers. pub fn delete_records_batch(&self, limit: u64) -> TransactionBuilder where C: AuditTrailFull + CoreClient, diff --git a/audit-trail-rs/src/core/records/transactions.rs b/audit-trail-rs/src/core/records/transactions.rs index db280e5e..1b880270 100644 --- a/audit-trail-rs/src/core/records/transactions.rs +++ b/audit-trail-rs/src/core/records/transactions.rs @@ -213,9 +213,12 @@ impl Transaction for DeleteRecord { /// Transaction that deletes multiple records in a batch operation. /// -/// The Move entry point skips locked records, deletes up to `limit` unlocked records in trail order, and returns -/// the deleted sequence numbers. The Rust implementation mirrors that output by collecting the matching -/// `RecordDeleted` events in order. +/// The Move entry point walks the trail from the front, silently skips records that are locked or whose tag is +/// not permitted by the capability, and deletes up to `limit` eligible records. Lock state — both count-based +/// and time-based — is evaluated against the trail snapshot and clock timestamp captured at the start of the +/// call, so the deletable set is stable for the batch's duration. The Rust implementation mirrors the Move +/// output by collecting the matching `RecordDeleted` events in deletion order; the returned vector may be +/// shorter than `limit` (or empty) and that is not an error. #[derive(Debug, Clone)] pub struct DeleteRecordsBatch { /// Trail object ID containing the records. diff --git a/audit-trail-rs/src/core/types/locking.rs b/audit-trail-rs/src/core/types/locking.rs index b083a912..ad98adb1 100644 --- a/audit-trail-rs/src/core/types/locking.rs +++ b/audit-trail-rs/src/core/types/locking.rs @@ -22,6 +22,21 @@ pub struct LockingConfig { } impl LockingConfig { + /// Validates the locking configuration without contacting the chain. + /// + /// Currently this rejects: + /// - [`LockingWindow::CountBased`] with `count == 0` (mirrors the Move `ECountWindowMustBePositive` abort). + /// - [`TimeLock::UntilDestroyed`] used as `delete_trail_lock` (mirrors the Move + /// `EUntilDestroyedNotSupportedForDeleteTrail` abort). `write_lock` may still be `UntilDestroyed`. + /// + /// Public entry points that accept a `LockingConfig` call this so that misconfiguration is reported + /// before any transaction is built. + pub fn validate(&self) -> Result<(), Error> { + self.delete_record_window.validate()?; + self.delete_trail_lock.validate_as_delete_trail_lock()?; + Ok(()) + } + /// Creates a new `Argument` from the `LockingConfig`. /// /// To be used when creating or updating locking config on the ledger. @@ -64,6 +79,20 @@ pub enum TimeLock { } impl TimeLock { + /// Validates this lock as a candidate for the trail-level delete lock. + /// + /// Rejects [`TimeLock::UntilDestroyed`] (mirrors the Move + /// `EUntilDestroyedNotSupportedForDeleteTrail` abort). All other variants are accepted; time-based + /// timestamp validity is enforced on-chain because it depends on the clock at execution time. + pub fn validate_as_delete_trail_lock(&self) -> Result<(), Error> { + if matches!(self, Self::UntilDestroyed) { + return Err(Error::InvalidArgument( + "TimeLock::UntilDestroyed is not supported as a delete-trail lock".to_string(), + )); + } + Ok(()) + } + pub(in crate::core) fn to_ptb(&self, ptb: &mut Ptb, package_id: ObjectID) -> Result { match self { Self::None => Ok(ptb.programmable_move_call( @@ -128,20 +157,41 @@ pub enum LockingWindow { }, /// Locks the last `count` records currently present in trail order. /// - /// Deletions can move older records into this protected window. The on-chain - /// check walks backward from the current tail, so delete gas scales linearly - /// with `count`. + /// The protected window is evaluated against the records present when the + /// transaction begins; concurrent additions are observed by subsequent + /// transactions only. `count` must be positive — use [`LockingWindow::None`] + /// to express "no deletion lock". Constructing this variant with `count == 0` + /// is rejected client-side with [`Error::InvalidArgument`] and would otherwise + /// abort on-chain with `ECountWindowMustBePositive`. + /// + /// The on-chain check walks backward from the current tail once per call, + /// so delete gas scales linearly with `count`. CountBased { - /// Number of current tail records protected from deletion. + /// Number of current tail records protected from deletion. Must be `> 0`. count: u64, }, } impl LockingWindow { + /// Validates the window configuration without contacting the chain. + /// + /// Rejects [`LockingWindow::CountBased`] with `count == 0` (mirrors the Move + /// `ECountWindowMustBePositive` abort). All other variants are always valid. + pub fn validate(&self) -> Result<(), Error> { + if let Self::CountBased { count: 0 } = self { + return Err(Error::InvalidArgument( + "LockingWindow::CountBased requires count > 0; use LockingWindow::None for no deletion lock" + .to_string(), + )); + } + Ok(()) + } + /// Creates a new `Argument` from the `LockingWindow`. /// /// To be used when creating or updating locking config on the ledger. pub(in crate::core) fn to_ptb(&self, ptb: &mut Ptb, package_id: ObjectID) -> Result { + self.validate()?; match self { Self::None => Ok(ptb.programmable_move_call( package_id, diff --git a/audit-trail-rs/tests/e2e/client.rs b/audit-trail-rs/tests/e2e/client.rs index 0bca63b4..846b0f71 100644 --- a/audit-trail-rs/tests/e2e/client.rs +++ b/audit-trail-rs/tests/e2e/client.rs @@ -219,7 +219,7 @@ impl TestClient { .create_trail() .with_initial_record(InitialRecord::new(data, None, None)) .with_record_tags(tags) - .finish() + .finish()? .build_and_execute(self) .await? .output; diff --git a/audit-trail-rs/tests/e2e/locking.rs b/audit-trail-rs/tests/e2e/locking.rs index 2d70f8f0..5a153c26 100644 --- a/audit-trail-rs/tests/e2e/locking.rs +++ b/audit-trail-rs/tests/e2e/locking.rs @@ -39,7 +39,7 @@ async fn update_locking_config_roundtrip() -> anyhow::Result<()> { trail .locking() - .update(config_with_window(LockingWindow::CountBased { count: 2 })) + .update(config_with_window(LockingWindow::CountBased { count: 2 }))? .build_and_execute(&client) .await?; @@ -63,7 +63,7 @@ async fn update_locking_config_switches_count_to_time_based() -> anyhow::Result< None, )) .with_locking_config(config_with_window(LockingWindow::CountBased { count: 3 })) - .finish() + .finish()? .build_and_execute(&client) .await? .output @@ -80,7 +80,7 @@ async fn update_locking_config_switches_count_to_time_based() -> anyhow::Result< trail .locking() - .update(config_with_window(LockingWindow::TimeBased { seconds: 300 })) + .update(config_with_window(LockingWindow::TimeBased { seconds: 300 }))? .build_and_execute(&client) .await?; @@ -111,7 +111,7 @@ async fn update_delete_record_window_roundtrip() -> anyhow::Result<()> { trail .locking() - .update_delete_record_window(LockingWindow::TimeBased { seconds: 120 }) + .update_delete_record_window(LockingWindow::TimeBased { seconds: 120 })? .build_and_execute(&client) .await?; @@ -142,7 +142,7 @@ async fn update_delete_trail_lock_roundtrip() -> anyhow::Result<()> { trail .locking() - .update_delete_trail_lock(TimeLock::Infinite) + .update_delete_trail_lock(TimeLock::Infinite)? .build_and_execute(&client) .await?; @@ -211,7 +211,7 @@ async fn update_locking_config_requires_permission() -> anyhow::Result<()> { let result = client .trail(trail_id) .locking() - .update(config_with_window(LockingWindow::TimeBased { seconds: 60 })) + .update(config_with_window(LockingWindow::TimeBased { seconds: 60 }))? .build_and_execute(&client) .await; @@ -252,7 +252,7 @@ async fn is_record_locked_supports_count_window_and_missing_sequence() -> anyhow .create_trail() .with_initial_record(InitialRecord::new(Data::text("trail-locking-status-e2e"), None, None)) .with_locking_config(config_with_window(LockingWindow::CountBased { count: 2 })) - .finish() + .finish()? .build_and_execute(&client) .await? .output @@ -305,7 +305,7 @@ async fn delete_window_variants_roundtrip() -> anyhow::Result<()> { trail .locking() - .update_delete_record_window(LockingWindow::TimeBased { seconds: 3600 }) + .update_delete_record_window(LockingWindow::TimeBased { seconds: 3600 })? .build_and_execute(&client) .await?; @@ -317,7 +317,7 @@ async fn delete_window_variants_roundtrip() -> anyhow::Result<()> { trail .locking() - .update_delete_record_window(LockingWindow::CountBased { count: 1 }) + .update_delete_record_window(LockingWindow::CountBased { count: 1 })? .build_and_execute(&client) .await?; @@ -329,7 +329,7 @@ async fn delete_window_variants_roundtrip() -> anyhow::Result<()> { trail .locking() - .update_delete_record_window(LockingWindow::None) + .update_delete_record_window(LockingWindow::None)? .build_and_execute(&client) .await?; @@ -367,7 +367,7 @@ async fn updated_time_lock_blocks_record_deletion() -> anyhow::Result<()> { trail .locking() - .update(config_with_window(LockingWindow::TimeBased { seconds: 3600 })) + .update(config_with_window(LockingWindow::TimeBased { seconds: 3600 }))? .build_and_execute(&client) .await?; @@ -399,7 +399,7 @@ async fn updated_delete_window_can_block_and_then_allow_deletion() -> anyhow::Re trail .locking() - .update_delete_record_window(LockingWindow::CountBased { count: 1 }) + .update_delete_record_window(LockingWindow::CountBased { count: 1 })? .build_and_execute(&client) .await?; @@ -411,7 +411,7 @@ async fn updated_delete_window_can_block_and_then_allow_deletion() -> anyhow::Re trail .locking() - .update_delete_record_window(LockingWindow::None) + .update_delete_record_window(LockingWindow::None)? .build_and_execute(&client) .await?; diff --git a/audit-trail-rs/tests/e2e/records.rs b/audit-trail-rs/tests/e2e/records.rs index 73dd1a49..cc902665 100644 --- a/audit-trail-rs/tests/e2e/records.rs +++ b/audit-trail-rs/tests/e2e/records.rs @@ -879,7 +879,7 @@ async fn delete_record_fails_while_time_locked() -> anyhow::Result<()> { .create_trail() .with_initial_record(InitialRecord::new(Data::text("locked"), None, None)) .with_locking_config(config_with_window(LockingWindow::TimeBased { seconds: 3600 })) - .finish() + .finish()? .build_and_execute(&client) .await? .output; @@ -942,7 +942,7 @@ async fn delete_record_fails_while_count_locked() -> anyhow::Result<()> { .create_trail() .with_initial_record(InitialRecord::new(Data::text("count-locked"), None, None)) .with_locking_config(config_with_window(LockingWindow::CountBased { count: 5 })) - .finish() + .finish()? .build_and_execute(&client) .await? .output; @@ -965,7 +965,7 @@ async fn delete_records_batch_respects_limit_and_deletes_oldest_first() -> anyho .create_trail() .with_initial_record(InitialRecord::new(Data::text("batch-initial"), None, None)) .with_locking_config(config_with_window(LockingWindow::None)) - .finish() + .finish()? .build_and_execute(&client) .await? .output; @@ -1034,7 +1034,7 @@ async fn delete_records_batch_skips_locked_records() -> anyhow::Result<()> { .create_trail() .with_initial_record(InitialRecord::new(Data::text("batch-skip-locked-initial"), None, None)) .with_locking_config(config_with_window(LockingWindow::CountBased { count: 1 })) - .finish() + .finish()? .build_and_execute(&client) .await? .output; diff --git a/audit-trail-rs/tests/e2e/trail.rs b/audit-trail-rs/tests/e2e/trail.rs index 89b72500..8e67f566 100644 --- a/audit-trail-rs/tests/e2e/trail.rs +++ b/audit-trail-rs/tests/e2e/trail.rs @@ -25,7 +25,7 @@ async fn create_trail_with_default_builder_settings() -> anyhow::Result<()> { let created = client .create_trail() .with_initial_record(InitialRecord::new(Data::text("audit-trail-create-default"), None, None)) - .finish() + .finish()? .build_and_execute(&client) .await? .output; @@ -47,7 +47,7 @@ async fn create_trail_with_default_builder_settings() -> anyhow::Result<()> { async fn create_empty_trail_with_default_builder_settings() -> anyhow::Result<()> { let client = get_funded_test_client().await?; - let created = client.create_trail().finish().build_and_execute(&client).await?.output; + let created = client.create_trail().finish()?.build_and_execute(&client).await?.output; assert_eq!(created.creator, client.sender_address()); @@ -80,7 +80,7 @@ async fn create_trail_with_metadata_and_time_lock() -> anyhow::Result<()> { .with_locking_config(config_with_window(LockingWindow::TimeBased { seconds: 300 })) .with_trail_metadata(immutable_metadata.clone()) .with_updatable_metadata("updatable metadata") - .finish() + .finish()? .build_and_execute(&client) .await? .output; @@ -109,7 +109,7 @@ async fn create_trail_with_bytes_and_count_lock() -> anyhow::Result<()> { )) .with_locking_config(config_with_window(LockingWindow::CountBased { count: 3 })) .with_trail_metadata_parts("Trail Count Lock", Some("count lock description".to_string())) - .finish() + .finish()? .build_and_execute(&client) .await? .output; @@ -133,7 +133,7 @@ async fn create_trail_with_custom_admin_address() -> anyhow::Result<()> { .create_trail() .with_admin(custom_admin) .with_initial_record(InitialRecord::new(Data::text("audit-trail-custom-admin"), None, None)) - .finish() + .finish()? .build_and_execute(&client) .await? .output; @@ -157,7 +157,7 @@ async fn get_returns_on_chain_trail() -> anyhow::Result<()> { .with_initial_record(InitialRecord::new(Data::text("trail-get-e2e"), None, None)) .with_trail_metadata_parts("Get Test", Some("description".into())) .with_updatable_metadata("initial updatable") - .finish() + .finish()? .build_and_execute(&client) .await? .output; @@ -187,7 +187,7 @@ async fn get_trail_without_metadata() -> anyhow::Result<()> { let created = client .create_trail() .with_initial_record(InitialRecord::new(Data::text("trail-no-meta-e2e"), None, None)) - .finish() + .finish()? .build_and_execute(&client) .await? .output; @@ -320,7 +320,7 @@ async fn update_metadata_does_not_affect_immutable_metadata() -> anyhow::Result< .with_initial_record(InitialRecord::new(Data::text("trail-immutable-check-e2e"), None, None)) .with_trail_metadata(immutable.clone()) .with_updatable_metadata("mutable") - .finish() + .finish()? .build_and_execute(&client) .await? .output; @@ -451,7 +451,7 @@ async fn delete_records_batch_then_delete_audit_trail_roundtrip() -> anyhow::Res .create_trail() .with_initial_record(InitialRecord::new(Data::text("trail-batch-delete-e2e"), None, None)) .with_locking_config(config_with_window(LockingWindow::None)) - .finish() + .finish()? .build_and_execute(&client) .await? .output; @@ -503,7 +503,7 @@ async fn manage_record_tag_registry_roundtrip() -> anyhow::Result<()> { .create_trail() .with_initial_record(InitialRecord::new(Data::text("trail-tag-registry"), None, None)) .with_record_tags(["finance"]) - .finish() + .finish()? .build_and_execute(&client) .await? .output; @@ -534,7 +534,7 @@ async fn remove_record_tag_rejects_in_use_tag() -> anyhow::Result<()> { .create_trail() .with_initial_record(InitialRecord::new(Data::text("trail-tag-in-use"), None, None)) .with_record_tags(["finance"]) - .finish() + .finish()? .build_and_execute(&client) .await? .output; @@ -571,7 +571,7 @@ async fn remove_record_tag_rejects_role_only_usage() -> anyhow::Result<()> { .create_trail() .with_initial_record(InitialRecord::new(Data::text("trail-tag-role-usage"), None, None)) .with_record_tags(["finance"]) - .finish() + .finish()? .build_and_execute(&client) .await? .output; diff --git a/bindings/wasm/audit_trail_wasm/src/builder.rs b/bindings/wasm/audit_trail_wasm/src/builder.rs index 0db57672..901438b1 100644 --- a/bindings/wasm/audit_trail_wasm/src/builder.rs +++ b/bindings/wasm/audit_trail_wasm/src/builder.rs @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 use audit_trail::core::builder::AuditTrailBuilder; -use iota_interaction_ts::wasm_error::Result; +use iota_interaction_ts::wasm_error::{Result, WasmResult}; use product_common::bindings::transaction::WasmTransactionBuilder; use product_common::bindings::utils::{into_transaction_builder, parse_wasm_iota_address}; use product_common::bindings::WasmIotaAddress; @@ -66,8 +66,11 @@ impl WasmAuditTrailBuilder { } /// Finalizes the builder into a transaction wrapper. + /// + /// Throws when the configured `LockingConfig` is invalid (e.g. `CountBased` window with `count == 0`). #[wasm_bindgen(unchecked_return_type = "TransactionBuilder")] pub fn finish(self) -> Result { - Ok(into_transaction_builder(WasmCreateTrail::new(self))) + let tx = self.0.finish().wasm_result()?.into_inner(); + Ok(into_transaction_builder(WasmCreateTrail(tx))) } } diff --git a/bindings/wasm/audit_trail_wasm/src/trail_handle/locking.rs b/bindings/wasm/audit_trail_wasm/src/trail_handle/locking.rs index 6c56d997..7f9694cb 100644 --- a/bindings/wasm/audit_trail_wasm/src/trail_handle/locking.rs +++ b/bindings/wasm/audit_trail_wasm/src/trail_handle/locking.rs @@ -47,6 +47,7 @@ impl WasmTrailLocking { .trail(self.trail_id) .locking() .update(config.into()) + .wasm_result()? .into_inner(); Ok(into_transaction_builder(WasmUpdateLockingConfig(tx))) } @@ -59,6 +60,7 @@ impl WasmTrailLocking { .trail(self.trail_id) .locking() .update_delete_record_window(window.into()) + .wasm_result()? .into_inner(); Ok(into_transaction_builder(WasmUpdateDeleteRecordWindow(tx))) } @@ -71,6 +73,7 @@ impl WasmTrailLocking { .trail(self.trail_id) .locking() .update_delete_trail_lock(lock.into()) + .wasm_result()? .into_inner(); Ok(into_transaction_builder(WasmUpdateDeleteTrailLock(tx))) } diff --git a/examples/audit-trail/01_create_audit_trail.rs b/examples/audit-trail/01_create_audit_trail.rs index 42a9422c..e19b5154 100644 --- a/examples/audit-trail/01_create_audit_trail.rs +++ b/examples/audit-trail/01_create_audit_trail.rs @@ -53,7 +53,7 @@ async fn main() -> Result<()> { Some("event:shipment_created;location:warehouse-a".to_string()), None, )) - .finish() + .finish()? .build_and_execute(&admin_client) .await? .output; diff --git a/examples/audit-trail/02_add_and_read_records.rs b/examples/audit-trail/02_add_and_read_records.rs index 7b1f37e6..7ad7c3f3 100644 --- a/examples/audit-trail/02_add_and_read_records.rs +++ b/examples/audit-trail/02_add_and_read_records.rs @@ -42,7 +42,7 @@ async fn main() -> Result<()> { Some("event:trail_created".to_string()), None, )) - .finish() + .finish()? .build_and_execute(&admin_client) .await? .output; diff --git a/examples/audit-trail/03_update_metadata.rs b/examples/audit-trail/03_update_metadata.rs index 8ef72075..2fe2e6fa 100644 --- a/examples/audit-trail/03_update_metadata.rs +++ b/examples/audit-trail/03_update_metadata.rs @@ -39,7 +39,7 @@ async fn main() -> Result<()> { Some("event:created".to_string()), None, )) - .finish() + .finish()? .build_and_execute(&admin_client) .await? .output; diff --git a/examples/audit-trail/04_configure_locking.rs b/examples/audit-trail/04_configure_locking.rs index 173a37db..2bef9894 100644 --- a/examples/audit-trail/04_configure_locking.rs +++ b/examples/audit-trail/04_configure_locking.rs @@ -34,7 +34,7 @@ async fn main() -> Result<()> { Some("event:created".to_string()), None, )) - .finish() + .finish()? .build_and_execute(&admin_client) .await? .output; @@ -127,13 +127,13 @@ async fn main() -> Result<()> { locking_admin_client .trail(trail_id) .locking() - .update_delete_record_window(LockingWindow::CountBased { count: 2 }) + .update_delete_record_window(LockingWindow::CountBased { count: 2 })? .build_and_execute(&locking_admin_client) .await?; locking_admin_client .trail(trail_id) .locking() - .update_delete_trail_lock(TimeLock::Infinite) + .update_delete_trail_lock(TimeLock::Infinite)? .build_and_execute(&locking_admin_client) .await?; diff --git a/examples/audit-trail/05_manage_access.rs b/examples/audit-trail/05_manage_access.rs index 90b087d2..5e17a436 100644 --- a/examples/audit-trail/05_manage_access.rs +++ b/examples/audit-trail/05_manage_access.rs @@ -35,7 +35,7 @@ async fn main() -> Result<()> { None, None, )) - .finish() + .finish()? .build_and_execute(&admin_client) .await? .output; diff --git a/examples/audit-trail/06_delete_records.rs b/examples/audit-trail/06_delete_records.rs index 1545de5f..05e9bd78 100644 --- a/examples/audit-trail/06_delete_records.rs +++ b/examples/audit-trail/06_delete_records.rs @@ -33,7 +33,7 @@ async fn main() -> Result<()> { Some("event:created".to_string()), None, )) - .finish() + .finish()? .build_and_execute(&admin_client) .await? .output; diff --git a/examples/audit-trail/07_access_read_only_methods.rs b/examples/audit-trail/07_access_read_only_methods.rs index 02872b66..25e23c9c 100644 --- a/examples/audit-trail/07_access_read_only_methods.rs +++ b/examples/audit-trail/07_access_read_only_methods.rs @@ -45,7 +45,7 @@ async fn main() -> Result<()> { Some("event:created".to_string()), None, )) - .finish() + .finish()? .build_and_execute(&admin_client) .await? .output; diff --git a/examples/audit-trail/08_delete_audit_trail.rs b/examples/audit-trail/08_delete_audit_trail.rs index 16b30dba..fbfa4eab 100644 --- a/examples/audit-trail/08_delete_audit_trail.rs +++ b/examples/audit-trail/08_delete_audit_trail.rs @@ -33,7 +33,7 @@ async fn main() -> Result<()> { Some("event:created".to_string()), None, )) - .finish() + .finish()? .build_and_execute(&admin_client) .await? .output; diff --git a/examples/audit-trail/advanced/09_tagged_records.rs b/examples/audit-trail/advanced/09_tagged_records.rs index dfb2c75a..0c085146 100644 --- a/examples/audit-trail/advanced/09_tagged_records.rs +++ b/examples/audit-trail/advanced/09_tagged_records.rs @@ -33,7 +33,7 @@ async fn main() -> Result<()> { Some("event:created".to_string()), None, )) - .finish() + .finish()? .build_and_execute(&admin_client) .await? .output; diff --git a/examples/audit-trail/advanced/10_capability_constraints.rs b/examples/audit-trail/advanced/10_capability_constraints.rs index 33390a5f..03757bf0 100644 --- a/examples/audit-trail/advanced/10_capability_constraints.rs +++ b/examples/audit-trail/advanced/10_capability_constraints.rs @@ -30,7 +30,7 @@ async fn main() -> Result<()> { let created_trail = admin_client .create_trail() .with_initial_record(InitialRecord::new(Data::text("Trail created"), None, None)) - .finish() + .finish()? .build_and_execute(&admin_client) .await? .output; diff --git a/examples/audit-trail/advanced/11_manage_record_tags.rs b/examples/audit-trail/advanced/11_manage_record_tags.rs index db77465c..9d7e7632 100644 --- a/examples/audit-trail/advanced/11_manage_record_tags.rs +++ b/examples/audit-trail/advanced/11_manage_record_tags.rs @@ -30,7 +30,7 @@ async fn main() -> Result<()> { .create_trail() .with_record_tags(["finance"]) .with_initial_record(InitialRecord::new(Data::text("Trail created"), None, None)) - .finish() + .finish()? .build_and_execute(&admin_client) .await? .output; diff --git a/examples/audit-trail/real-world/01_customs_clearance.rs b/examples/audit-trail/real-world/01_customs_clearance.rs index 59415850..1d202ef2 100644 --- a/examples/audit-trail/real-world/01_customs_clearance.rs +++ b/examples/audit-trail/real-world/01_customs_clearance.rs @@ -70,7 +70,7 @@ async fn main() -> Result<()> { Some("event:case_opened".to_string()), Some("documents".to_string()), )) - .finish() + .finish()? .build_and_execute(&admin_client) .await? .output; diff --git a/examples/audit-trail/real-world/02_clinical_trial.rs b/examples/audit-trail/real-world/02_clinical_trial.rs index b67c2686..50c3b171 100644 --- a/examples/audit-trail/real-world/02_clinical_trial.rs +++ b/examples/audit-trail/real-world/02_clinical_trial.rs @@ -75,7 +75,7 @@ async fn main() -> Result<()> { Some("event:trial_opened".to_string()), Some("enrollment".to_string()), )) - .finish() + .finish()? .build_and_execute(&admin_client) .await? .output; @@ -318,7 +318,7 @@ async fn main() -> Result<()> { data_safety_board_client .trail(trail_id) .locking() - .update_delete_trail_lock(TimeLock::Infinite) + .update_delete_trail_lock(TimeLock::Infinite)? .build_and_execute(&data_safety_board_client) .await?; diff --git a/examples/audit-trail/real-world/03_digital_product_passport.rs b/examples/audit-trail/real-world/03_digital_product_passport.rs index f20b6dca..24ce8b80 100644 --- a/examples/audit-trail/real-world/03_digital_product_passport.rs +++ b/examples/audit-trail/real-world/03_digital_product_passport.rs @@ -99,7 +99,7 @@ async fn main() -> Result<()> { Some("event:dpp_created".to_string()), Some("manufacturing".to_string()), )) - .finish() + .finish()? .build_and_execute(&manufacturer_client) .await? .output; From 760c7df49b78faed92779b7b250e80a982274973 Mon Sep 17 00:00:00 2001 From: chrisgitiota Date: Thu, 21 May 2026 10:19:50 +0200 Subject: [PATCH 187/189] Fix fmt issues --- audit-trail-rs/src/core/locking/mod.rs | 6 ++++-- audit-trail-rs/src/core/records/mod.rs | 12 ++++++------ 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/audit-trail-rs/src/core/locking/mod.rs b/audit-trail-rs/src/core/locking/mod.rs index 18268f8d..8cbf206a 100644 --- a/audit-trail-rs/src/core/locking/mod.rs +++ b/audit-trail-rs/src/core/locking/mod.rs @@ -54,8 +54,10 @@ impl<'a, C> TrailLocking<'a, C> { /// # Errors /// /// Returns [`Error::InvalidArgument`] when `config` contains: - /// * `delete_record_window` using [`LockingWindow::CountBased`] with `count == 0` (mirrors the Move `ECountWindowMustBePositive` abort). - /// * `delete_trail_lock` using [`TimeLock::UntilDestroyed`] (mirrors the Move `EUntilDestroyedNotSupportedForDeleteTrail` abort). + /// * `delete_record_window` using [`LockingWindow::CountBased`] with `count == 0` (mirrors the Move + /// `ECountWindowMustBePositive` abort). + /// * `delete_trail_lock` using [`TimeLock::UntilDestroyed`] (mirrors the Move + /// `EUntilDestroyedNotSupportedForDeleteTrail` abort). pub fn update(&self, config: LockingConfig) -> Result, Error> where C: AuditTrailFull + CoreClient, diff --git a/audit-trail-rs/src/core/records/mod.rs b/audit-trail-rs/src/core/records/mod.rs index 0a2af145..952cc689 100644 --- a/audit-trail-rs/src/core/records/mod.rs +++ b/audit-trail-rs/src/core/records/mod.rs @@ -128,12 +128,12 @@ impl<'a, C, D> TrailRecords<'a, C, D> { /// /// # Caveats /// - /// - **Partial progress is not an error.** An empty returned vector means every front-to-back candidate was - /// either locked or tag-filtered out. - /// - **Tag filtering is silent.** A capability with a narrow tag scope can make the batch appear to stop - /// early while locked-and-disallowed records still exist further back. - /// - **Gas and object-size limits.** The call walks and mutates inline; prefer modest `limit` values and - /// repeat the call rather than passing a single large `limit`. + /// - **Partial progress is not an error.** An empty returned vector means every front-to-back candidate was either + /// locked or tag-filtered out. + /// - **Tag filtering is silent.** A capability with a narrow tag scope can make the batch appear to stop early + /// while locked-and-disallowed records still exist further back. + /// - **Gas and object-size limits.** The call walks and mutates inline; prefer modest `limit` values and repeat the + /// call rather than passing a single large `limit`. /// - **Order is fixed.** Use [`Self::delete`] to target specific sequence numbers. pub fn delete_records_batch(&self, limit: u64) -> TransactionBuilder where From 9e782b175c80d361b4383455993594e5991aeead Mon Sep 17 00:00:00 2001 From: chrisgitiota Date: Thu, 21 May 2026 10:23:52 +0200 Subject: [PATCH 188/189] Fix prettier-move issues --- audit-trail-move/sources/audit_trail.move | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/audit-trail-move/sources/audit_trail.move b/audit-trail-move/sources/audit_trail.move index 9a7b23c7..3aedeb59 100644 --- a/audit-trail-move/sources/audit_trail.move +++ b/audit-trail-move/sources/audit_trail.move @@ -571,13 +571,15 @@ public fun delete_records_batch( let sequence_number = current.destroy_some(); current = *self.records.next(sequence_number); - if (is_record_locked_in_window( - &self.records, - sequence_number, - &lock_window, - &count_lock_threshold, - timestamp, - )) { + if ( + is_record_locked_in_window( + &self.records, + sequence_number, + &lock_window, + &count_lock_threshold, + timestamp, + ) + ) { continue }; From 1871a78435daf2cfb999fa0678f42a4baf985e3e Mon Sep 17 00:00:00 2001 From: chrisgitiota Date: Thu, 21 May 2026 10:49:14 +0200 Subject: [PATCH 189/189] Fix clippy issues --- audit-trail-rs/src/core/builder.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/audit-trail-rs/src/core/builder.rs b/audit-trail-rs/src/core/builder.rs index 8ad7425f..7f332c8f 100644 --- a/audit-trail-rs/src/core/builder.rs +++ b/audit-trail-rs/src/core/builder.rs @@ -8,7 +8,7 @@ use std::collections::HashSet; use iota_interaction::types::base_types::IotaAddress; use product_common::transaction::transaction_builder::TransactionBuilder; -use super::types::{Data, ImmutableMetadata, InitialRecord, LockingConfig, LockingWindow, TimeLock}; +use super::types::{Data, ImmutableMetadata, InitialRecord, LockingConfig}; use crate::core::create::CreateTrail; use crate::error::Error;