From 848dd197fa651efc13c2f10f082df4f8a2a50063 Mon Sep 17 00:00:00 2001 From: Yasir Date: Sat, 14 Feb 2026 12:42:32 +0300 Subject: [PATCH 01/18] feat: add last admin capability prevention of deletion --- .../examples/counter/sources/counter.move | 2 +- components_move/sources/capability.move | 55 ++- components_move/sources/role_map.move | 358 +++++++++++------- .../tests/capability_component_tests.move | 34 +- components_move/tests/role_map_tests.move | 162 +++++++- 5 files changed, 421 insertions(+), 190 deletions(-) diff --git a/components_move/examples/counter/sources/counter.move b/components_move/examples/counter/sources/counter.move index 94d081f..21b6e45 100644 --- a/components_move/examples/counter/sources/counter.move +++ b/components_move/examples/counter/sources/counter.move @@ -76,7 +76,7 @@ public fun create(ctx: &mut TxContext): (Capability, ID) { (admin_cap, counter_id) } -public fun increment(counter: &mut Counter, cap: &Capability, clock: &Clock, ctx: &TxContext) { +public fun increment(counter: &mut Counter, cap: &Capability, clock: &Clock, ctx: &mut TxContext) { assert!( counter .access diff --git a/components_move/sources/capability.move b/components_move/sources/capability.move index 95dae9c..2a16505 100644 --- a/components_move/sources/capability.move +++ b/components_move/sources/capability.move @@ -21,11 +21,8 @@ public struct Capability has key, store { /// The target_key of the RoleMap instance this capability applies to. target_key: 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 + /// For whom has this capability been issued (optional) issued_to: Option
, /// Optional validity period start timestamp (in milliseconds since Unix epoch). /// * The specified timestamp is included in the validity period @@ -76,56 +73,56 @@ public(package) fun new_capability( } /// Get the capability's ID -public fun id(cap: &Capability): ID { - object::uid_to_inner(&cap.id) +public fun id(self: &Capability): ID { + object::uid_to_inner(&self.id) } /// Get the capability's role -public fun role(cap: &Capability): &String { - &cap.role +public fun role(self: &Capability): &String { + &self.role } /// Get the capability's target_key -public fun target_key(cap: &Capability): ID { - cap.target_key +public fun target_key(self: &Capability): ID { + self.target_key } /// Check if the capability has a specific role -public fun has_role(cap: &Capability, role: &String): bool { - &cap.role == role +public fun has_role(self: &Capability, role: &String): bool { + &self.role == role } // Get the capability's issued_to address -public fun issued_to(cap: &Capability): &Option
{ - &cap.issued_to +public fun issued_to(self: &Capability): &Option
{ + &self.issued_to } // Get the capability's valid_from timestamp -public fun valid_from(cap: &Capability): &Option { - &cap.valid_from +public fun valid_from(self: &Capability): &Option { + &self.valid_from } // Get the capability's valid_until timestamp -public fun valid_until(cap: &Capability): &Option { - &cap.valid_until +public fun valid_until(self: &Capability): &Option { + &self.valid_until } // Check if the capability is currently valid for `clock::timestamp_ms(clock)` -public fun is_currently_valid(cap: &Capability, clock: &Clock): bool { +public fun is_currently_valid(self: &Capability, clock: &Clock): bool { let current_ts = clock::timestamp_ms(clock); - cap.is_valid_for_timestamp(current_ts) + self.is_valid_for_timestamp(current_ts) } // Check if the capability is valid for a specific timestamp (in milliseconds since Unix epoch) -public fun is_valid_for_timestamp(cap: &Capability, timestamp_ms: u64): bool { - let valid_from_ok = if (cap.valid_from.is_some()) { - let from = cap.valid_from.borrow(); +public fun is_valid_for_timestamp(self: &Capability, timestamp_ms: u64): bool { + let valid_from_ok = if (self.valid_from.is_some()) { + let from = self.valid_from.borrow(); timestamp_ms >= *from } else { true }; - let valid_until_ok = if (cap.valid_until.is_some()) { - let until = cap.valid_until.borrow(); + let valid_until_ok = if (self.valid_until.is_some()) { + let until = self.valid_until.borrow(); timestamp_ms <= *until } else { true @@ -134,7 +131,7 @@ public fun is_valid_for_timestamp(cap: &Capability, timestamp_ms: u64): bool { } /// Destroy a capability -public(package) fun destroy(cap: Capability) { +public(package) fun destroy(self: Capability) { let Capability { id, role: _role, @@ -142,11 +139,11 @@ public(package) fun destroy(cap: Capability) { issued_to: _issued_to, valid_from: _valid_from, valid_until: _valid_until, - } = cap; + } = self; object::delete(id); } #[test_only] -public fun destroy_for_testing(cap: Capability) { - destroy(cap); +public fun destroy_for_testing(self: Capability) { + destroy(self); } diff --git a/components_move/sources/role_map.move b/components_move/sources/role_map.move index addbeb2..2ce6c27 100644 --- a/components_move/sources/role_map.move +++ b/components_move/sources/role_map.move @@ -26,11 +26,8 @@ use iota::vec_set::{Self, VecSet}; use std::string::String; use tf_components::capability::{Self, Capability}; -// =============== Errors ========================================================== +// =============== 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"; @@ -49,8 +46,19 @@ const ECapabilityIssuedToMismatch: vector = #[error] const ECapabilityPermissionDenied: vector = b"The role associated with provided capability does not have the required permission"; +#[error] +const ECapabilityNotIssued: vector = + b"The specified capability is not currently issued by this `RoleMap`"; +#[error] +const EInitialAdminPermissionsInconsistent: vector = + b"The initial admin role must include all configured role and capability admin permissions"; +#[error] +const EInitialAdminRoleCannotBeDeleted: vector = b"The initial admin role cannot be deleted"; +#[error] +const ELastInitialAdminCapability: vector = + b"Cannot revoke or destroy the last issued capability of the initial admin role"; -// =============== Events ========================================================== +// =============== Events ==================== /// Emitted when a capability is issued public struct CapabilityIssued has copy, drop { @@ -78,9 +86,25 @@ public struct CapabilityRevoked has copy, drop { capability_id: ID, } -// TODO: Add event for Role creation, removing, updating, etc. +/// Emitted when a role is created +public struct RoleCreated has copy, drop { + target_key: ID, + role: String, +} + +/// Emitted when a role is removed +public struct RoleRemoved has copy, drop { + target_key: ID, + role: String, +} -// =============== Core Types ====================================================== +/// Emitted when a role is updated +public struct RoleUpdated has copy, drop { + target_key: ID, + role: String, +} + +// =============== Core Types ==================== /// Defines the permissions required to administer roles in this RoleMap public struct RoleAdminPermissions has copy, drop, store { @@ -109,16 +133,20 @@ public struct RoleMap has copy, drop, store { /// to share the used roles and capabilities between these objects. target_key: ID, /// Mapping of role names to their associated permissions - roles: VecMap>, + roles: VecMap>, + /// Name of the initial admin role created by `new`. + initial_admin_role_name: String, /// Allowlist of all issued capability IDs issued_capabilities: VecSet, + /// Capability IDs currently issued for the initial admin role. + initial_admin_cap_ids: 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 ==================== +// ========== Role & Capability AdminPermissions Functions =========== public fun new_role_admin_permissions( add: P, @@ -142,7 +170,7 @@ public fun new_capability_admin_permissions( } } -// =============== RoleMap Functions =============================================== +// ============ RoleMap Functions ==================== /// Create a new RoleMap with an initial admin role /// The initial admin role is created with the specified name and permissions @@ -157,16 +185,17 @@ public fun new_capability_admin_permissions( /// The target_key to associate this RoleMap with the initial admin capability /// and all other created capabilities. Usually this is the ID of the managed onchain object /// (i.e. an audit_trail::AuditTrail or the tf_components::Counter). -/// - 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 +/// - `initial_admin_role_name`: The name of the initial admin role +/// - `initial_admin_role_permissions`: Permissions granted to that role. +/// - `role_admin_permissions`: Permissions required to manage roles. +/// - `capability_admin_permissions`: Permissions required to manage +/// capabilities. +/// - `ctx`: The transaction context +/// +/// Errors: +/// - Aborts with `EInitialAdminPermissionsInconsistent` if `initial_admin_role_permissions` +/// does not include all permissions configured in `role_admin_permissions` and +/// `capability_admin_permissions`. public fun new( target_key: ID, initial_admin_role_name: String, @@ -175,25 +204,38 @@ public fun new( capability_admin_permissions: CapabilityAdminPermissions

, ctx: &mut TxContext, ): (RoleMap

, Capability) { + assert!( + has_required_admin_permissions( + &initial_admin_role_permissions, + &role_admin_permissions, + &capability_admin_permissions, + ), + EInitialAdminPermissionsInconsistent, + ); + let mut roles = vec_map::empty>(); - roles.insert(initial_admin_role_name, initial_admin_role_permissions); + roles.insert(copy initial_admin_role_name, initial_admin_role_permissions); let admin_cap = capability::new_capability( - initial_admin_role_name, + copy initial_admin_role_name, target_key, - std::option::none(), - std::option::none(), - std::option::none(), + option::none(), + option::none(), + option::none(), ctx, ); let mut issued_capabilities = vec_set::empty(); issued_capabilities.insert(admin_cap.id()); + let mut initial_admin_cap_ids = vec_set::empty(); + initial_admin_cap_ids.insert(admin_cap.id()); let role_map = RoleMap { roles, + initial_admin_role_name, role_admin_permissions, capability_admin_permissions, target_key, issued_capabilities, + initial_admin_cap_ids, }; (role_map, admin_cap) @@ -201,104 +243,121 @@ public fun new( /// 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) +public fun get_role_permissions(self: &RoleMap

, role: &String): &VecSet

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

, + self: &mut RoleMap

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

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

, + self: &mut RoleMap

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

, + self: &mut RoleMap

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

, clock: &Clock, - ctx: &TxContext, + ctx: &mut TxContext, ) { - assert!( - role_map.is_capability_valid( - cap, - &role_map.role_admin_permissions.update, - clock, - ctx, - ), - EPermissionDenied, + self.is_capability_valid( + cap, + &self.role_admin_permissions.update, + clock, + ctx, ); - 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); + if (*role == self.initial_admin_role_name) { + assert!( + has_required_admin_permissions( + &new_permissions, + &self.role_admin_permissions, + &self.capability_admin_permissions, + ), + EInitialAdminPermissionsInconsistent, + ); + }; + + assert!(vec_map::contains(&self.roles, role), ERoleDoesNotExist); + vec_map::remove(&mut self.roles, role); + vec_map::insert(&mut self.roles, *role, new_permissions); + + event::emit(RoleUpdated { + target_key: self.target_key, + role: *role, + }); } /// 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) +public fun has_role(self: &RoleMap

, role: &String): bool { + vec_map::contains(&self.roles, role) } -// =============== Capability related Functions ==================================== - +/// ===== Capability Functions ======= /// Indicates if a provided capability is valid. /// /// A capability is considered valid if: /// - The capability's target_key matches the RoleMap's target_key. /// 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. +/// 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. +/// 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. +/// 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. +/// Aborts with `ECapabilityIssuedToMismatch` if the addresses do not match. /// /// Parameters /// ---------- -/// - role_map: Reference to the `RoleMap` mapping. +/// - self: 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. @@ -308,21 +367,18 @@ public fun has_role(role_map: &RoleMap

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

, + self: &RoleMap

, cap: &Capability, permission: &P, clock: &Clock, - ctx: &TxContext, + ctx: &mut TxContext, ): bool { - assert!( - role_map.target_key == cap.target_key(), - ECapabilitySecurityVaultIdMismatch, - ); + assert!(self.target_key == cap.target_key(), ECapabilitySecurityVaultIdMismatch); - let permissions = role_map.get_role_permissions(cap.role()); + let permissions = self.get_role_permissions(cap.role()); assert!(vec_set::contains(permissions, permission), ECapabilityPermissionDenied); - assert!(role_map.issued_capabilities.contains(&cap.id()), ECapabilityHasBeenRevoked); + assert!(self.issued_capabilities.contains(&cap.id()), ECapabilityHasBeenRevoked); if (cap.valid_from().is_some() || cap.valid_until().is_some()) { assert!(cap.is_currently_valid(clock), ECapabilityTimeConstraintsNotMet); @@ -352,14 +408,14 @@ public fun is_capability_valid( /// /// Returns the newly created capability. /// -/// Sends a CapabilityIssued event upon successful creation. +/// 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 tf_components::capability::EValidityPeriodInconsistent if the provided valid_from and valid_until are inconsistent. +/// - Aborts with any error documented by `is_capability_valid` if the provided capability fails authorization checks. +/// - Aborts with `ERoleDoesNotExist` if the specified role does not exist in the role_map. +/// - Aborts with `tf_components::capability::EValidityPeriodInconsistent` if the provided valid_from and valid_until are inconsistent. public fun new_capability( - role_map: &mut RoleMap

, + self: &mut RoleMap

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

, @@ -368,53 +424,45 @@ public fun new_capability( clock: &Clock, ctx: &mut TxContext, ): Capability { - assert!( - role_map.is_capability_valid( - cap, - &role_map.capability_admin_permissions.add, - clock, - ctx, - ), - EPermissionDenied, + self.is_capability_valid( + cap, + &self.capability_admin_permissions.add, + clock, + ctx, ); - assert!(role_map.roles.contains(role), ERoleDoesNotExist); + assert!(self.roles.contains(role), ERoleDoesNotExist); let new_cap = capability::new_capability( *role, - role_map.target_key, + self.target_key, issued_to, valid_from, valid_until, ctx, ); - register_new_capability(role_map, &new_cap); + issue_capability(self, &new_cap); new_cap } /// Destroy an existing capability /// Every owner of a capability is allowed to destroy it when no longer needed. +/// This operation is intentionally not gated by `CapabilityAdminPermissions::revoke`. /// -/// 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.target_key == cap_to_destroy.target_key(), - ECapabilitySecurityVaultIdMismatch, - ); +/// Sends a `CapabilityDestroyed` event upon successful destruction. +public fun destroy_capability(self: &mut RoleMap

, cap_to_destroy: Capability) { + assert!(self.target_key == cap_to_destroy.target_key(), ECapabilitySecurityVaultIdMismatch); - if (role_map.issued_capabilities.contains(&cap_to_destroy.id())) { + if (self.issued_capabilities.contains(&cap_to_destroy.id())) { + assert_can_remove_initial_admin_capability(self, &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()); + self.issued_capabilities.remove(&cap_to_destroy.id()); + if (self.initial_admin_cap_ids.contains(&cap_to_destroy.id())) { + self.initial_admin_cap_ids.remove(&cap_to_destroy.id()); + }; }; event::emit(CapabilityDestroyed { - target_key: role_map.target_key, + target_key: self.target_key, capability_id: cap_to_destroy.id(), role: *cap_to_destroy.role(), issued_to: *cap_to_destroy.issued_to(), @@ -427,42 +475,72 @@ public fun destroy_capability( /// Revoke an existing capability /// -/// Sends a CapabilityRevoked event upon successful revocation. +/// 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. +/// - Aborts with any error documented by `is_capability_valid` if the provided capability fails authorization checks. +/// - Aborts with `ECapabilityNotIssued` if `cap_to_revoke` is not currently issued by this `RoleMap`. public fun revoke_capability( - role_map: &mut RoleMap

, + self: &mut RoleMap

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

, new_cap: &Capability) { - role_map.issued_capabilities.insert(new_cap.id()); +/// Checks if the provided permissions include all required admin permissions +/// +/// Returns true if the provided permissions include all required admin +fun has_required_admin_permissions( + permissions: &VecSet

, + role_admin_permissions: &RoleAdminPermissions

, + capability_admin_permissions: &CapabilityAdminPermissions

, +): bool { + permissions.contains(&role_admin_permissions.add) && + permissions.contains(&role_admin_permissions.delete) && + permissions.contains(&role_admin_permissions.update) && + permissions.contains(&capability_admin_permissions.add) && + permissions.contains(&capability_admin_permissions.revoke) +} + +/// Asserts that the initial admin capability can be removed +/// +/// Errors: +/// - Aborts with `ELastInitialAdminCapability` if the initial admin capability cannot be removed. +fun assert_can_remove_initial_admin_capability(self: &RoleMap

, cap_id: &ID) { + if (self.initial_admin_cap_ids.contains(cap_id)) { + assert!(self.initial_admin_cap_ids.size() > 1, ELastInitialAdminCapability); + }; +} + +/// Issues a new capability +fun issue_capability(self: &mut RoleMap

, new_cap: &Capability) { + self.issued_capabilities.insert(new_cap.id()); + if (new_cap.role() == &self.initial_admin_role_name) { + self.initial_admin_cap_ids.insert(new_cap.id()); + }; event::emit(CapabilityIssued { - target_key: role_map.target_key, + target_key: self.target_key, capability_id: new_cap.id(), role: *new_cap.role(), issued_to: *new_cap.issued_to(), @@ -471,23 +549,23 @@ fun register_new_capability(role_map: &mut RoleMap

, new_cap: }); } -// =============== Getter Functions ================================================ +// =============== 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) +public fun size(self: &RoleMap

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

): ID { - role_map.target_key +public fun target_key(self: &RoleMap

): ID { + self.target_key } //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 role_admin_permissions(self: &RoleMap

): &RoleAdminPermissions

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

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

): &VecSet { + &self.issued_capabilities } diff --git a/components_move/tests/capability_component_tests.move b/components_move/tests/capability_component_tests.move index f248abc..c14917d 100644 --- a/components_move/tests/capability_component_tests.move +++ b/components_move/tests/capability_component_tests.move @@ -14,7 +14,7 @@ fun test_capability_created_with_correct_field_values() { let admin_user = @0xAD; let mut scenario = ts::begin(admin_user); - let (mut role_map, admin_cap, target_key) = test_utils::create_test_role_map( + let (_role_map, admin_cap, target_key) = test_utils::create_test_role_map( ts::ctx(&mut scenario), ); @@ -29,7 +29,7 @@ fun test_capability_created_with_correct_field_values() { assert!(admin_cap.valid_until().is_none(), 4); // Cleanup - role_map.destroy_capability(admin_cap); + transfer::public_transfer(admin_cap, admin_user); ts::end(scenario); } @@ -38,7 +38,7 @@ fun test_has_role_returns_correct_values() { let admin_user = @0xAD; let mut scenario = ts::begin(admin_user); - let (mut role_map, admin_cap, _security_vault_id) = test_utils::create_test_role_map( + let (_role_map, admin_cap, _security_vault_id) = test_utils::create_test_role_map( ts::ctx(&mut scenario), ); @@ -48,7 +48,7 @@ fun test_has_role_returns_correct_values() { assert!(!admin_cap.has_role(&b"User".to_string()), 0); // Cleanup - role_map.destroy_capability(admin_cap); + transfer::public_transfer(admin_cap, admin_user); ts::end(scenario); } @@ -84,7 +84,7 @@ fun test_capability_issued_to_specific_address() { // Cleanup iota::clock::destroy_for_testing(clock); - role_map.destroy_capability(admin_cap); + transfer::public_transfer(admin_cap, admin_user); role_map.destroy_capability(user_cap); ts::end(scenario); } @@ -125,7 +125,7 @@ fun test_capability_valid_from_and_valid_until() { // Cleanup iota::clock::destroy_for_testing(clock); - role_map.destroy_capability(admin_cap); + transfer::public_transfer(admin_cap, admin_user); role_map.destroy_capability(timed_cap); ts::end(scenario); } @@ -137,7 +137,7 @@ fun test_is_valid_for_timestamp_no_restrictions() { let admin_user = @0xAD; let mut scenario = ts::begin(admin_user); - let (mut role_map, admin_cap, _security_vault_id) = test_utils::create_test_role_map( + let (_role_map, admin_cap, _security_vault_id) = test_utils::create_test_role_map( ts::ctx(&mut scenario), ); @@ -147,7 +147,7 @@ fun test_is_valid_for_timestamp_no_restrictions() { assert!(admin_cap.is_valid_for_timestamp(999999999), 2); // Cleanup - role_map.destroy_capability(admin_cap); + transfer::public_transfer(admin_cap, admin_user); ts::end(scenario); } @@ -184,7 +184,7 @@ fun test_is_valid_for_timestamp_with_valid_from() { assert!(timed_cap.is_valid_for_timestamp(1000001), 2); // Cleanup iota::clock::destroy_for_testing(clock); - role_map.destroy_capability(admin_cap); + transfer::public_transfer(admin_cap, admin_user); role_map.destroy_capability(timed_cap); ts::end(scenario); } @@ -223,7 +223,7 @@ fun test_is_valid_for_timestamp_with_valid_until() { // Cleanup iota::clock::destroy_for_testing(clock); - role_map.destroy_capability(admin_cap); + transfer::public_transfer(admin_cap, admin_user); role_map.destroy_capability(timed_cap); ts::end(scenario); } @@ -267,7 +267,7 @@ fun test_is_valid_for_timestamp_with_both_restrictions() { // Cleanup iota::clock::destroy_for_testing(clock); - role_map.destroy_capability(admin_cap); + transfer::public_transfer(admin_cap, admin_user); role_map.destroy_capability(timed_cap); ts::end(scenario); } @@ -279,7 +279,7 @@ fun test_is_currently_valid_no_restrictions() { let admin_user = @0xAD; let mut scenario = ts::begin(admin_user); - let (mut role_map, admin_cap, _security_vault_id) = test_utils::create_test_role_map( + let (_role_map, admin_cap, _security_vault_id) = test_utils::create_test_role_map( ts::ctx(&mut scenario), ); @@ -291,7 +291,7 @@ fun test_is_currently_valid_no_restrictions() { // Cleanup iota::clock::destroy_for_testing(clock); - role_map.destroy_capability(admin_cap); + transfer::public_transfer(admin_cap, admin_user); ts::end(scenario); } @@ -328,7 +328,7 @@ fun test_is_currently_valid_within_validity_period() { // Cleanup iota::clock::destroy_for_testing(clock); - role_map.destroy_capability(admin_cap); + transfer::public_transfer(admin_cap, admin_user); role_map.destroy_capability(timed_cap); ts::end(scenario); } @@ -366,7 +366,7 @@ fun test_is_currently_valid_before_validity_period() { // Cleanup iota::clock::destroy_for_testing(clock); - role_map.destroy_capability(admin_cap); + transfer::public_transfer(admin_cap, admin_user); role_map.destroy_capability(timed_cap); ts::end(scenario); } @@ -404,7 +404,7 @@ fun test_is_currently_valid_after_validity_period() { // Cleanup iota::clock::destroy_for_testing(clock); - role_map.destroy_capability(admin_cap); + transfer::public_transfer(admin_cap, admin_user); role_map.destroy_capability(timed_cap); ts::end(scenario); } @@ -450,7 +450,7 @@ fun test_capability_with_all_restrictions() { // Cleanup iota::clock::destroy_for_testing(clock); - role_map.destroy_capability(admin_cap); + transfer::public_transfer(admin_cap, admin_user); role_map.destroy_capability(restricted_cap); ts::end(scenario); } diff --git a/components_move/tests/role_map_tests.move b/components_move/tests/role_map_tests.move index 469c154..c4946c2 100644 --- a/components_move/tests/role_map_tests.move +++ b/components_move/tests/role_map_tests.move @@ -1,6 +1,6 @@ // Copyright (c) 2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 - +#[allow(lint(abort_without_constant))] #[test_only] module tf_components::role_map_tests; @@ -76,10 +76,166 @@ fun test_role_based_permission_delegation() { iota::clock::destroy_for_testing(clock); }; - - role_map.destroy_capability(admin_cap); + // can't destroy the admin cap here because it's the last initial admin capability + transfer::public_transfer(admin_cap, admin_user); // Cleanup ts::next_tx(&mut scenario, admin_user); ts::end(scenario); } + +#[test] +#[expected_failure(abort_code = role_map::EInitialAdminPermissionsInconsistent)] +fun test_new_fails_with_empty_initial_admin_permissions() { + let ( + role_admin_permissions, + capability_admin_permissions, + ) = test_utils::get_admin_permissions(); + + let admin_user = @0xAD; + let mut scenario = ts::begin(admin_user); + + let target_key = test_utils::fake_object_id_from_string( + &b"This is a test Vault ID String".to_string(), + ); + + let empty_permissions = vec_set::empty(); + + let (mut role_map, admin_cap) = role_map::new( + target_key, + b"SuperAdmin".to_string(), + empty_permissions, + role_admin_permissions, + capability_admin_permissions, + ts::ctx(&mut scenario), + ); + + role_map.destroy_capability(admin_cap); + + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = role_map::EInitialAdminRoleCannotBeDeleted)] +fun test_delete_initial_admin_role_fails() { + let admin_user = @0xAD; + let mut scenario = ts::begin(admin_user); + + let (mut role_map, admin_cap, _target_key) = test_utils::create_test_role_map( + ts::ctx(&mut scenario), + ); + + let clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); + let initial_role = test_utils::initial_admin_role_name(); + role_map.delete_role( + &admin_cap, + &initial_role, + &clock, + ts::ctx(&mut scenario), + ); + + iota::clock::destroy_for_testing(clock); + role_map.destroy_capability(admin_cap); + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = role_map::EInitialAdminPermissionsInconsistent)] +fun test_update_initial_admin_role_removing_required_permissions_fails() { + let admin_user = @0xAD; + let mut scenario = ts::begin(admin_user); + + let (mut role_map, admin_cap, _target_key) = test_utils::create_test_role_map( + ts::ctx(&mut scenario), + ); + + let clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); + let initial_role = test_utils::initial_admin_role_name(); + + role_map.update_role_permissions( + &admin_cap, + &initial_role, + vec_set::singleton(test_utils::manage_roles()), + &clock, + ts::ctx(&mut scenario), + ); + + iota::clock::destroy_for_testing(clock); + role_map.destroy_capability(admin_cap); + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = role_map::ELastInitialAdminCapability)] +fun test_revoke_last_initial_admin_capability_fails() { + let admin_user = @0xAD; + let mut scenario = ts::begin(admin_user); + + let (mut role_map, admin_cap, _target_key) = test_utils::create_test_role_map( + ts::ctx(&mut scenario), + ); + + let clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); + role_map.revoke_capability( + &admin_cap, + admin_cap.id(), + &clock, + ts::ctx(&mut scenario), + ); + + iota::clock::destroy_for_testing(clock); + role_map.destroy_capability(admin_cap); + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = role_map::ELastInitialAdminCapability)] +fun test_destroy_last_initial_admin_capability_fails() { + let admin_user = @0xAD; + let mut scenario = ts::begin(admin_user); + + let (mut role_map, admin_cap, _target_key) = test_utils::create_test_role_map( + ts::ctx(&mut scenario), + ); + + role_map.destroy_capability(admin_cap); + ts::end(scenario); +} + +#[test] +fun test_initial_admin_capability_rotation_works() { + let admin_user = @0xAD; + let mut scenario = ts::begin(admin_user); + + let (mut role_map, admin_cap, _target_key) = test_utils::create_test_role_map( + ts::ctx(&mut scenario), + ); + + let clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); + let initial_role = test_utils::initial_admin_role_name(); + let rotated_admin_cap = role_map.new_capability( + &admin_cap, + &initial_role, + std::option::none(), + std::option::none(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + let rotated_admin_cap_id = rotated_admin_cap.id(); + + role_map.revoke_capability( + &admin_cap, + admin_cap.id(), + &clock, + ts::ctx(&mut scenario), + ); + + assert!(role_map.issued_capabilities().size() == 1, 0); + assert!(role_map.issued_capabilities().contains(&rotated_admin_cap_id), 1); + + iota::clock::destroy_for_testing(clock); + role_map.destroy_capability(admin_cap); + transfer::public_transfer(rotated_admin_cap, admin_user); + ts::end(scenario); +} From 763bea2d43d68c303fb910704ace465e7db17303 Mon Sep 17 00:00:00 2001 From: Yasir Date: Mon, 16 Feb 2026 10:57:32 +0300 Subject: [PATCH 02/18] feat: allow lint for abort_without_constant in capability_component_tests --- components_move/tests/capability_component_tests.move | 1 + 1 file changed, 1 insertion(+) diff --git a/components_move/tests/capability_component_tests.move b/components_move/tests/capability_component_tests.move index c14917d..f72ba4d 100644 --- a/components_move/tests/capability_component_tests.move +++ b/components_move/tests/capability_component_tests.move @@ -1,6 +1,7 @@ // Copyright (c) 2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +#[allow(lint(abort_without_constant))] #[test_only] module tf_components::capability_component_tests; From 7df0efd8273c6347da52f5cb87f18cc03d4fc70b Mon Sep 17 00:00:00 2001 From: Yasir Date: Mon, 16 Feb 2026 13:09:41 +0300 Subject: [PATCH 03/18] feat: implement explicit destruction and revocation for initial admin capabilities --- components_move/sources/capability.move | 10 +- components_move/sources/role_map.move | 123 ++++++-- .../tests/capability_component_tests.move | 18 +- components_move/tests/role_map_tests.move | 292 +++++++++++++++++- 4 files changed, 399 insertions(+), 44 deletions(-) diff --git a/components_move/sources/capability.move b/components_move/sources/capability.move index 2a16505..1da4462 100644 --- a/components_move/sources/capability.move +++ b/components_move/sources/capability.move @@ -134,11 +134,11 @@ public fun is_valid_for_timestamp(self: &Capability, timestamp_ms: u64): bool { public(package) fun destroy(self: Capability) { let Capability { id, - role: _role, - target_key: _target_key, - issued_to: _issued_to, - valid_from: _valid_from, - valid_until: _valid_until, + role: _, + target_key: _, + issued_to: _, + valid_from: _, + valid_until: _, } = self; object::delete(id); } diff --git a/components_move/sources/role_map.move b/components_move/sources/role_map.move index 2ce6c27..427e691 100644 --- a/components_move/sources/role_map.move +++ b/components_move/sources/role_map.move @@ -55,8 +55,11 @@ const EInitialAdminPermissionsInconsistent: vector = #[error] const EInitialAdminRoleCannotBeDeleted: vector = b"The initial admin role cannot be deleted"; #[error] -const ELastInitialAdminCapability: vector = - b"Cannot revoke or destroy the last issued capability of the initial admin role"; +const EInitialAdminCapabilityMustBeExplicitlyDestroyed: vector = + b"Initial admin capabilities cannot be revoked or destroyed via this function. Use revoke_initial_admin_capability or destroy_initial_admin_capability instead"; +#[error] +const ECapabilityIsNotInitialAdmin: vector = + b"This capability is not an initial admin capability"; // =============== Events ==================== @@ -448,17 +451,19 @@ public fun new_capability( /// Every owner of a capability is allowed to destroy it when no longer needed. /// This operation is intentionally not gated by `CapabilityAdminPermissions::revoke`. /// +/// Initial admin capabilities cannot be destroyed via this function. +/// Use `destroy_initial_admin_capability` instead. +/// /// Sends a `CapabilityDestroyed` event upon successful destruction. public fun destroy_capability(self: &mut RoleMap

, cap_to_destroy: Capability) { assert!(self.target_key == cap_to_destroy.target_key(), ECapabilitySecurityVaultIdMismatch); + assert!( + !self.initial_admin_cap_ids.contains(&cap_to_destroy.id()), + EInitialAdminCapabilityMustBeExplicitlyDestroyed, + ); if (self.issued_capabilities.contains(&cap_to_destroy.id())) { - assert_can_remove_initial_admin_capability(self, &cap_to_destroy.id()); - // Capability has not been revoked before destroying, so let's remove it now self.issued_capabilities.remove(&cap_to_destroy.id()); - if (self.initial_admin_cap_ids.contains(&cap_to_destroy.id())) { - self.initial_admin_cap_ids.remove(&cap_to_destroy.id()); - }; }; event::emit(CapabilityDestroyed { @@ -475,11 +480,15 @@ public fun destroy_capability(self: &mut RoleMap

, cap_to_dest /// Revoke an existing capability /// +/// Initial admin capabilities cannot be revoked via this function. +/// Use `revoke_initial_admin_capability` instead. +/// /// Sends a `CapabilityRevoked` event upon successful revocation. /// /// Errors: /// - Aborts with any error documented by `is_capability_valid` if the provided capability fails authorization checks. /// - Aborts with `ECapabilityNotIssued` if `cap_to_revoke` is not currently issued by this `RoleMap`. +/// - Aborts with `EInitialAdminCapabilityMustBeExplicitlyDestroyed` if `cap_to_revoke` is an initial admin capability. public fun revoke_capability( self: &mut RoleMap

, cap: &Capability, @@ -495,11 +504,93 @@ public fun revoke_capability( ); assert!(self.issued_capabilities.contains(&cap_to_revoke), ECapabilityNotIssued); - assert_can_remove_initial_admin_capability(self, &cap_to_revoke); + assert!( + !self.initial_admin_cap_ids.contains(&cap_to_revoke), + EInitialAdminCapabilityMustBeExplicitlyDestroyed, + ); self.issued_capabilities.remove(&cap_to_revoke); - if (self.initial_admin_cap_ids.contains(&cap_to_revoke)) { - self.initial_admin_cap_ids.remove(&cap_to_revoke); - }; + + event::emit(CapabilityRevoked { + target_key: self.target_key, + capability_id: cap_to_revoke, + }); +} + +/// Destroy an initial admin capability. +/// +/// This is the only way to destroy a capability associated with the initial admin role. +/// Every owner of an initial admin capability is allowed to destroy it when no longer needed. +/// This operation is intentionally not gated by `CapabilityAdminPermissions::revoke`. +/// +/// WARNING: If all initial admin capabilities are destroyed, the RoleMap will be permanently +/// sealed with no admin access possible. +/// +/// Sends a `CapabilityDestroyed` event upon successful destruction. +/// +/// Errors: +/// - Aborts with `ECapabilitySecurityVaultIdMismatch` if the capability's target_key does not match. +/// - Aborts with `ECapabilityIsNotInitialAdmin` if the capability is not an initial admin capability. +public fun destroy_initial_admin_capability( + self: &mut RoleMap

, + cap_to_destroy: Capability, +) { + assert!(self.target_key == cap_to_destroy.target_key(), ECapabilitySecurityVaultIdMismatch); + assert!( + self.initial_admin_cap_ids.contains(&cap_to_destroy.id()), + ECapabilityIsNotInitialAdmin, + ); + + self.issued_capabilities.remove(&cap_to_destroy.id()); + self.initial_admin_cap_ids.remove(&cap_to_destroy.id()); + + event::emit(CapabilityDestroyed { + target_key: self.target_key, + 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 initial admin capability. +/// +/// This is the only way to revoke a capability associated with the initial admin role. +/// Requires `CapabilityAdminPermissions::revoke` permission. +/// +/// WARNING: If all initial admin capabilities are revoked, the RoleMap will be permanently +/// sealed with no admin access possible. +/// +/// Sends a `CapabilityRevoked` event upon successful revocation. +/// +/// Errors: +/// - Aborts with any error documented by `is_capability_valid` if the provided capability fails authorization checks. +/// - Aborts with `ECapabilityNotIssued` if `cap_to_revoke` is not currently issued by this `RoleMap`. +/// - Aborts with `ECapabilityIsNotInitialAdmin` if `cap_to_revoke` is not an initial admin capability. +public fun revoke_initial_admin_capability( + self: &mut RoleMap

, + cap: &Capability, + cap_to_revoke: ID, + clock: &Clock, + ctx: &mut TxContext, +) { + self.is_capability_valid( + cap, + &self.capability_admin_permissions.revoke, + clock, + ctx, + ); + + assert!(self.issued_capabilities.contains(&cap_to_revoke), ECapabilityNotIssued); + assert!( + self.initial_admin_cap_ids.contains(&cap_to_revoke), + ECapabilityIsNotInitialAdmin, + ); + + self.issued_capabilities.remove(&cap_to_revoke); + self.initial_admin_cap_ids.remove(&cap_to_revoke); event::emit(CapabilityRevoked { target_key: self.target_key, @@ -522,16 +613,6 @@ fun has_required_admin_permissions( permissions.contains(&capability_admin_permissions.revoke) } -/// Asserts that the initial admin capability can be removed -/// -/// Errors: -/// - Aborts with `ELastInitialAdminCapability` if the initial admin capability cannot be removed. -fun assert_can_remove_initial_admin_capability(self: &RoleMap

, cap_id: &ID) { - if (self.initial_admin_cap_ids.contains(cap_id)) { - assert!(self.initial_admin_cap_ids.size() > 1, ELastInitialAdminCapability); - }; -} - /// Issues a new capability fun issue_capability(self: &mut RoleMap

, new_cap: &Capability) { self.issued_capabilities.insert(new_cap.id()); diff --git a/components_move/tests/capability_component_tests.move b/components_move/tests/capability_component_tests.move index f72ba4d..e43a5a1 100644 --- a/components_move/tests/capability_component_tests.move +++ b/components_move/tests/capability_component_tests.move @@ -86,7 +86,7 @@ fun test_capability_issued_to_specific_address() { // Cleanup iota::clock::destroy_for_testing(clock); transfer::public_transfer(admin_cap, admin_user); - role_map.destroy_capability(user_cap); + role_map.destroy_initial_admin_capability(user_cap); ts::end(scenario); } @@ -127,7 +127,7 @@ fun test_capability_valid_from_and_valid_until() { // Cleanup iota::clock::destroy_for_testing(clock); transfer::public_transfer(admin_cap, admin_user); - role_map.destroy_capability(timed_cap); + role_map.destroy_initial_admin_capability(timed_cap); ts::end(scenario); } @@ -186,7 +186,7 @@ fun test_is_valid_for_timestamp_with_valid_from() { // Cleanup iota::clock::destroy_for_testing(clock); transfer::public_transfer(admin_cap, admin_user); - role_map.destroy_capability(timed_cap); + role_map.destroy_initial_admin_capability(timed_cap); ts::end(scenario); } @@ -225,7 +225,7 @@ fun test_is_valid_for_timestamp_with_valid_until() { // Cleanup iota::clock::destroy_for_testing(clock); transfer::public_transfer(admin_cap, admin_user); - role_map.destroy_capability(timed_cap); + role_map.destroy_initial_admin_capability(timed_cap); ts::end(scenario); } @@ -269,7 +269,7 @@ fun test_is_valid_for_timestamp_with_both_restrictions() { // Cleanup iota::clock::destroy_for_testing(clock); transfer::public_transfer(admin_cap, admin_user); - role_map.destroy_capability(timed_cap); + role_map.destroy_initial_admin_capability(timed_cap); ts::end(scenario); } @@ -330,7 +330,7 @@ fun test_is_currently_valid_within_validity_period() { // Cleanup iota::clock::destroy_for_testing(clock); transfer::public_transfer(admin_cap, admin_user); - role_map.destroy_capability(timed_cap); + role_map.destroy_initial_admin_capability(timed_cap); ts::end(scenario); } @@ -368,7 +368,7 @@ fun test_is_currently_valid_before_validity_period() { // Cleanup iota::clock::destroy_for_testing(clock); transfer::public_transfer(admin_cap, admin_user); - role_map.destroy_capability(timed_cap); + role_map.destroy_initial_admin_capability(timed_cap); ts::end(scenario); } @@ -406,7 +406,7 @@ fun test_is_currently_valid_after_validity_period() { // Cleanup iota::clock::destroy_for_testing(clock); transfer::public_transfer(admin_cap, admin_user); - role_map.destroy_capability(timed_cap); + role_map.destroy_initial_admin_capability(timed_cap); ts::end(scenario); } @@ -452,6 +452,6 @@ fun test_capability_with_all_restrictions() { // Cleanup iota::clock::destroy_for_testing(clock); transfer::public_transfer(admin_cap, admin_user); - role_map.destroy_capability(restricted_cap); + role_map.destroy_initial_admin_capability(restricted_cap); ts::end(scenario); } diff --git a/components_move/tests/role_map_tests.move b/components_move/tests/role_map_tests.move index c4946c2..44486a8 100644 --- a/components_move/tests/role_map_tests.move +++ b/components_move/tests/role_map_tests.move @@ -76,7 +76,7 @@ fun test_role_based_permission_delegation() { iota::clock::destroy_for_testing(clock); }; - // can't destroy the admin cap here because it's the last initial admin capability + transfer::public_transfer(admin_cap, admin_user); // Cleanup @@ -110,7 +110,7 @@ fun test_new_fails_with_empty_initial_admin_permissions() { ts::ctx(&mut scenario), ); - role_map.destroy_capability(admin_cap); + role_map.destroy_initial_admin_capability(admin_cap); ts::end(scenario); } @@ -135,7 +135,7 @@ fun test_delete_initial_admin_role_fails() { ); iota::clock::destroy_for_testing(clock); - role_map.destroy_capability(admin_cap); + role_map.destroy_initial_admin_capability(admin_cap); ts::end(scenario); } @@ -161,13 +161,15 @@ fun test_update_initial_admin_role_removing_required_permissions_fails() { ); iota::clock::destroy_for_testing(clock); - role_map.destroy_capability(admin_cap); + role_map.destroy_initial_admin_capability(admin_cap); ts::end(scenario); } +// ===== Tests: normal revoke/destroy blocked for initial admin caps ===== + #[test] -#[expected_failure(abort_code = role_map::ELastInitialAdminCapability)] -fun test_revoke_last_initial_admin_capability_fails() { +#[expected_failure(abort_code = role_map::EInitialAdminCapabilityMustBeExplicitlyDestroyed)] +fun test_revoke_initial_admin_capability_blocked_on_normal_revoke() { let admin_user = @0xAD; let mut scenario = ts::begin(admin_user); @@ -184,13 +186,189 @@ fun test_revoke_last_initial_admin_capability_fails() { ); iota::clock::destroy_for_testing(clock); + role_map.destroy_initial_admin_capability(admin_cap); + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = role_map::EInitialAdminCapabilityMustBeExplicitlyDestroyed)] +fun test_destroy_initial_admin_capability_blocked_on_normal_destroy() { + let admin_user = @0xAD; + let mut scenario = ts::begin(admin_user); + + let (mut role_map, admin_cap, _target_key) = test_utils::create_test_role_map( + ts::ctx(&mut scenario), + ); + + role_map.destroy_capability(admin_cap); + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = role_map::EInitialAdminCapabilityMustBeExplicitlyDestroyed)] +fun test_revoke_second_initial_admin_capability_blocked_on_normal_revoke() { + let admin_user = @0xAD; + let mut scenario = ts::begin(admin_user); + + let (mut role_map, admin_cap, _target_key) = test_utils::create_test_role_map( + ts::ctx(&mut scenario), + ); + + let clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); + let initial_role = test_utils::initial_admin_role_name(); + + // Issue a second admin cap + let second_admin_cap = role_map.new_capability( + &admin_cap, + &initial_role, + std::option::none(), + std::option::none(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + // Try to revoke the second one via normal revoke — should fail even with multiple admin caps + role_map.revoke_capability( + &admin_cap, + second_admin_cap.id(), + &clock, + ts::ctx(&mut scenario), + ); + + iota::clock::destroy_for_testing(clock); + role_map.destroy_initial_admin_capability(admin_cap); + role_map.destroy_initial_admin_capability(second_admin_cap); + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = role_map::EInitialAdminCapabilityMustBeExplicitlyDestroyed)] +fun test_destroy_second_initial_admin_capability_blocked_on_normal_destroy() { + let admin_user = @0xAD; + let mut scenario = ts::begin(admin_user); + + let (mut role_map, admin_cap, _target_key) = test_utils::create_test_role_map( + ts::ctx(&mut scenario), + ); + + let clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); + let initial_role = test_utils::initial_admin_role_name(); + + // Issue a second admin cap + let second_admin_cap = role_map.new_capability( + &admin_cap, + &initial_role, + std::option::none(), + std::option::none(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + // Try to destroy the second one via normal destroy — should fail + iota::clock::destroy_for_testing(clock); + transfer::public_transfer(admin_cap, admin_user); + role_map.destroy_capability(second_admin_cap); + ts::end(scenario); +} + +#[test] +fun test_destroy_initial_admin_capability_works() { + let admin_user = @0xAD; + let mut scenario = ts::begin(admin_user); + + let (mut role_map, admin_cap, _target_key) = test_utils::create_test_role_map( + ts::ctx(&mut scenario), + ); + + let clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); + let initial_role = test_utils::initial_admin_role_name(); + + // Issue a second admin cap so we can destroy the first + let second_admin_cap = role_map.new_capability( + &admin_cap, + &initial_role, + std::option::none(), + std::option::none(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + let second_admin_cap_id = second_admin_cap.id(); + + // Destroy the first admin cap via explicit API + role_map.destroy_initial_admin_capability(admin_cap); + + assert!(role_map.issued_capabilities().size() == 1, 0); + assert!(role_map.issued_capabilities().contains(&second_admin_cap_id), 1); + + iota::clock::destroy_for_testing(clock); + transfer::public_transfer(second_admin_cap, admin_user); + ts::end(scenario); +} + +#[test] +fun test_revoke_initial_admin_capability_works() { + let admin_user = @0xAD; + let mut scenario = ts::begin(admin_user); + + let (mut role_map, admin_cap, _target_key) = test_utils::create_test_role_map( + ts::ctx(&mut scenario), + ); + + let clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); + let initial_role = test_utils::initial_admin_role_name(); + + // Issue a second admin cap + let second_admin_cap = role_map.new_capability( + &admin_cap, + &initial_role, + std::option::none(), + std::option::none(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + // Revoke the first admin cap via explicit API + role_map.revoke_initial_admin_capability( + &second_admin_cap, + admin_cap.id(), + &clock, + ts::ctx(&mut scenario), + ); + + assert!(role_map.issued_capabilities().size() == 1, 0); + assert!(role_map.issued_capabilities().contains(&second_admin_cap.id()), 1); + + // The revoked cap object can still be destroyed via normal destroy (no longer in initial_admin_cap_ids) role_map.destroy_capability(admin_cap); + + iota::clock::destroy_for_testing(clock); + transfer::public_transfer(second_admin_cap, admin_user); + ts::end(scenario); +} + +#[test] +fun test_destroy_last_initial_admin_capability_seals_role_map() { + let admin_user = @0xAD; + let mut scenario = ts::begin(admin_user); + + let (mut role_map, admin_cap, _target_key) = test_utils::create_test_role_map( + ts::ctx(&mut scenario), + ); + + // Destroy the only admin cap — seals the RoleMap + role_map.destroy_initial_admin_capability(admin_cap); + + assert!(role_map.issued_capabilities().size() == 0, 0); + ts::end(scenario); } #[test] -#[expected_failure(abort_code = role_map::ELastInitialAdminCapability)] -fun test_destroy_last_initial_admin_capability_fails() { +fun test_revoke_last_initial_admin_capability_seals_role_map() { let admin_user = @0xAD; let mut scenario = ts::begin(admin_user); @@ -198,6 +376,20 @@ fun test_destroy_last_initial_admin_capability_fails() { ts::ctx(&mut scenario), ); + let clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); + + // Revoke the only admin cap — seals the RoleMap + role_map.revoke_initial_admin_capability( + &admin_cap, + admin_cap.id(), + &clock, + ts::ctx(&mut scenario), + ); + + assert!(role_map.issued_capabilities().size() == 0, 0); + + iota::clock::destroy_for_testing(clock); + // The revoked cap can still be destroyed for cleanup role_map.destroy_capability(admin_cap); ts::end(scenario); } @@ -224,7 +416,8 @@ fun test_initial_admin_capability_rotation_works() { ); let rotated_admin_cap_id = rotated_admin_cap.id(); - role_map.revoke_capability( + // Use the explicit API to revoke the old admin cap + role_map.revoke_initial_admin_capability( &admin_cap, admin_cap.id(), &clock, @@ -239,3 +432,84 @@ fun test_initial_admin_capability_rotation_works() { transfer::public_transfer(rotated_admin_cap, admin_user); ts::end(scenario); } + +#[test] +#[expected_failure(abort_code = role_map::ECapabilityIsNotInitialAdmin)] +fun test_destroy_initial_admin_capability_rejects_non_admin_cap() { + let admin_user = @0xAD; + let mut scenario = ts::begin(admin_user); + + let (mut role_map, admin_cap, _target_key) = test_utils::create_test_role_map( + ts::ctx(&mut scenario), + ); + + let clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); + + // Create a non-admin role and capability + role_map.create_role( + &admin_cap, + string::utf8(b"Reader"), + vec_set::singleton(test_utils::manage_roles()), + &clock, + ts::ctx(&mut scenario), + ); + let reader_cap = role_map.new_capability( + &admin_cap, + &string::utf8(b"Reader"), + std::option::none(), + std::option::none(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + // Try to destroy reader cap via the explicit admin API — should fail + iota::clock::destroy_for_testing(clock); + transfer::public_transfer(admin_cap, admin_user); + role_map.destroy_initial_admin_capability(reader_cap); + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = role_map::ECapabilityIsNotInitialAdmin)] +fun test_revoke_initial_admin_capability_rejects_non_admin_cap() { + let admin_user = @0xAD; + let mut scenario = ts::begin(admin_user); + + let (mut role_map, admin_cap, _target_key) = test_utils::create_test_role_map( + ts::ctx(&mut scenario), + ); + + let clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); + + // Create a non-admin role and capability + role_map.create_role( + &admin_cap, + string::utf8(b"Reader"), + vec_set::singleton(test_utils::manage_roles()), + &clock, + ts::ctx(&mut scenario), + ); + let reader_cap = role_map.new_capability( + &admin_cap, + &string::utf8(b"Reader"), + std::option::none(), + std::option::none(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + // Try to revoke reader cap via the explicit admin API — should fail + role_map.revoke_initial_admin_capability( + &admin_cap, + reader_cap.id(), + &clock, + ts::ctx(&mut scenario), + ); + + iota::clock::destroy_for_testing(clock); + transfer::public_transfer(admin_cap, admin_user); + role_map.destroy_capability(reader_cap); + ts::end(scenario); +} From 819f796c930f7543860467ed3da5c0df3ddca244 Mon Sep 17 00:00:00 2001 From: Yasir Date: Mon, 16 Feb 2026 13:11:28 +0300 Subject: [PATCH 04/18] feat: add Prettier configuration and update Move.lock dependencies --- components_move/.prettierignore | 1 + components_move/.prettierrc | 8 ++++++++ components_move/Move.lock | 12 ++++++------ .../examples/counter/sources/counter.move | 8 +++++--- .../examples/counter/tests/counter_tests.move | 8 +++++--- components_move/sources/role_map.move | 10 ++-------- components_move/sources/timelock.move | 3 +-- components_move/tests/core_test_utils.move | 3 +-- components_move/tests/role_map_tests.move | 6 ++---- components_move/tests/timelock_tests.move | 8 ++++---- 10 files changed, 35 insertions(+), 32 deletions(-) create mode 100644 components_move/.prettierignore create mode 100644 components_move/.prettierrc diff --git a/components_move/.prettierignore b/components_move/.prettierignore new file mode 100644 index 0000000..07ed706 --- /dev/null +++ b/components_move/.prettierignore @@ -0,0 +1 @@ +build/* \ No newline at end of file diff --git a/components_move/.prettierrc b/components_move/.prettierrc new file mode 100644 index 0000000..0ceb306 --- /dev/null +++ b/components_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/components_move/Move.lock b/components_move/Move.lock index f42557d..2af1373 100644 --- a/components_move/Move.lock +++ b/components_move/Move.lock @@ -2,7 +2,7 @@ [move] version = 3 -manifest_digest = "172E6E9D27DFB64487EDC62DB907AD3642D5693302E78F3727A67B53C44DAD6C" +manifest_digest = "4E219538E560B52E657002599823A7F5794C484FDEB0EA7DE93DD860C8821C44" deps_digest = "F9B494B64F0615AED0E98FC12A85B85ECD2BC5185C22D30E7F67786BB52E507C" dependencies = [ { id = "Iota", name = "Iota" }, @@ -13,7 +13,7 @@ dependencies = [ [[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 +21,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,11 +30,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" }, @@ -42,6 +42,6 @@ dependencies = [ ] [move.toolchain-version] -compiler-version = "1.14.1" +compiler-version = "1.16.2" edition = "2024.beta" flavor = "iota" diff --git a/components_move/examples/counter/sources/counter.move b/components_move/examples/counter/sources/counter.move index 21b6e45..897b1fa 100644 --- a/components_move/examples/counter/sources/counter.move +++ b/components_move/examples/counter/sources/counter.move @@ -6,9 +6,11 @@ module tf_components::counter; use iota::clock::Clock; -use tf_components::capability::Capability; -use tf_components::counter_permission::{Self as permission, CounterPermission}; -use tf_components::role_map; +use tf_components::{ + capability::Capability, + counter_permission::{Self as permission, CounterPermission}, + role_map +}; #[error] const EPermissionDenied: vector = diff --git a/components_move/examples/counter/tests/counter_tests.move b/components_move/examples/counter/tests/counter_tests.move index dd5ecae..eb1273d 100644 --- a/components_move/examples/counter/tests/counter_tests.move +++ b/components_move/examples/counter/tests/counter_tests.move @@ -6,9 +6,11 @@ module tf_components::example_counter_tests; use iota::test_scenario as ts; use std::string; -use tf_components::capability::Capability; -use tf_components::counter::{Self, Counter}; -use tf_components::counter_permission as permission; +use tf_components::{ + capability::Capability, + counter::{Self, Counter}, + counter_permission as permission +}; /// Test capability lifecycle: creation, usage, revocation and destruction in a complete workflow. #[test] diff --git a/components_move/sources/role_map.move b/components_move/sources/role_map.move index 427e691..8cdc8af 100644 --- a/components_move/sources/role_map.move +++ b/components_move/sources/role_map.move @@ -19,10 +19,7 @@ /// module tf_components::role_map; -use iota::clock::Clock; -use iota::event; -use iota::vec_map::{Self, VecMap}; -use iota::vec_set::{Self, VecSet}; +use iota::{clock::Clock, event, vec_map::{Self, VecMap}, vec_set::{Self, VecSet}}; use std::string::String; use tf_components::capability::{Self, Capability}; @@ -584,10 +581,7 @@ public fun revoke_initial_admin_capability( ); assert!(self.issued_capabilities.contains(&cap_to_revoke), ECapabilityNotIssued); - assert!( - self.initial_admin_cap_ids.contains(&cap_to_revoke), - ECapabilityIsNotInitialAdmin, - ); + assert!(self.initial_admin_cap_ids.contains(&cap_to_revoke), ECapabilityIsNotInitialAdmin); self.issued_capabilities.remove(&cap_to_revoke); self.initial_admin_cap_ids.remove(&cap_to_revoke); diff --git a/components_move/sources/timelock.move b/components_move/sources/timelock.move index e89d65a..c5b6382 100644 --- a/components_move/sources/timelock.move +++ b/components_move/sources/timelock.move @@ -79,7 +79,6 @@ public fun is_unlock_at(lock_time: &TimeLock): bool { } } - /// Checks if the provided lock time is a UnlockAt lock. public fun is_unlock_at_ms(lock_time: &TimeLock): bool { match (lock_time) { @@ -192,4 +191,4 @@ public fun destroy_for_testing(lock: TimeLock) { TimeLock::None => {}, TimeLock::Infinite => {}, } -} \ No newline at end of file +} diff --git a/components_move/tests/core_test_utils.move b/components_move/tests/core_test_utils.move index 93e7366..5cca190 100644 --- a/components_move/tests/core_test_utils.move +++ b/components_move/tests/core_test_utils.move @@ -4,8 +4,7 @@ #[test_only] module tf_components::core_test_utils; -use iota::object::id_from_bytes; -use iota::vec_set::{Self, VecSet}; +use iota::{object::id_from_bytes, vec_set::{Self, VecSet}}; use std::string::String; /// Simple Permission set for RoleMap tests diff --git a/components_move/tests/role_map_tests.move b/components_move/tests/role_map_tests.move index 44486a8..a7d0e21 100644 --- a/components_move/tests/role_map_tests.move +++ b/components_move/tests/role_map_tests.move @@ -4,11 +4,9 @@ #[test_only] module tf_components::role_map_tests; -use iota::test_scenario as ts; -use iota::vec_set; +use iota::{test_scenario as ts, vec_set}; use std::string; -use tf_components::core_test_utils as test_utils; -use tf_components::role_map; +use tf_components::{core_test_utils as test_utils, role_map}; #[test] fun test_role_based_permission_delegation() { diff --git a/components_move/tests/timelock_tests.move b/components_move/tests/timelock_tests.move index 0b4a8e4..df788e9 100644 --- a/components_move/tests/timelock_tests.move +++ b/components_move/tests/timelock_tests.move @@ -376,7 +376,7 @@ public fun test_infinite_lock() { // Note: Infinite lock cannot be destroyed (tested separately) // Therefore we wrw using a test-only destroy here timelock::destroy_for_testing(lock); - clock::destroy_for_testing(clock); + clock::destroy_for_testing(clock); ts.end(); } @@ -450,7 +450,7 @@ public fun test_infinite_vs_until_destroyed() { // This should fail with ETimelockNotExpired timelock::destroy(infinite_lock, &clock); - + // These should never be reached clock::destroy_for_testing(clock); ts.end(); @@ -514,8 +514,8 @@ public fun test_all_lock_types_type_checks() { // Infinite lock can not be destroyed as usual, using test-only destroy instead timelock::destroy_for_testing(infinite_lock); - + // Cleanup clock::destroy_for_testing(clock); - ts.end(); + ts.end(); } From dc2c566a6d72195dd0c0f7990b3fc59c32adb817 Mon Sep 17 00:00:00 2001 From: Yasir Date: Tue, 24 Feb 2026 13:00:48 +0300 Subject: [PATCH 05/18] feat: Add drop capability to TimeLock enum for improved resource management --- components_move/sources/timelock.move | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components_move/sources/timelock.move b/components_move/sources/timelock.move index c5b6382..3333ff6 100644 --- a/components_move/sources/timelock.move +++ b/components_move/sources/timelock.move @@ -17,7 +17,7 @@ const ETimelockNotExpired: u64 = 1; /// Represents different types of time-based locks that can be applied to /// onchain objects. -public enum TimeLock has store { +public enum TimeLock has store, drop { /// A lock that unlocks at a specific Unix timestamp (seconds since Unix epoch) UnlockAt(u32), /// Same as UnlockAt (unlocks at specific timestamp) but using milliseconds since Unix epoch From 7c12271a62cbfa6e78b3198308c254ac178c03ab Mon Sep 17 00:00:00 2001 From: Yasir Date: Tue, 3 Mar 2026 12:37:30 +0300 Subject: [PATCH 06/18] feat: Enhance RoleMap documentation and clarify initial admin role protections --- components_move/sources/role_map.move | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/components_move/sources/role_map.move b/components_move/sources/role_map.move index 094bfae..6d156e4 100644 --- a/components_move/sources/role_map.move +++ b/components_move/sources/role_map.move @@ -21,6 +21,9 @@ /// - Validates `Capability`s against the defined roles to facilitate proper access control by the integrating module /// (function `RoleMap.is_capability_valid()`) /// - All functions are access restricted by custom permissions defined during `RoleMap` instantiation +/// - Stores the initial admin role name in `initial_admin_role_name` +/// - Tracks active initial admin capability IDs in `initial_admin_cap_ids` +/// - Requires explicit initial-admin revoke/destroy APIs for those IDs /// /// Examples: /// - The TF product Audit Trails uses `RoleMap` to manage access to the audit trail records and their operations. @@ -144,10 +147,13 @@ public struct RoleMap has copy, drop, store { /// Mapping of role names to their associated permissions roles: VecMap>, /// Name of the initial admin role created by `new`. + /// The RoleMap uses this to protect that role from unsafe changes. initial_admin_role_name: String, /// Allowlist of all issued capability IDs issued_capabilities: VecSet, - /// Capability IDs currently issued for the initial admin role. + /// IDs of active capabilities for the initial admin role. + /// These IDs cannot be removed through generic revoke/destroy functions. + /// Use `revoke_initial_admin_capability` or `destroy_initial_admin_capability` instead. initial_admin_cap_ids: VecSet, /// Permissions required to administer roles in this RoleMap role_admin_permissions: RoleAdminPermissions

, From 6c457b15f5d40db148b105b62b887cc5ae94e2cb Mon Sep 17 00:00:00 2001 From: Yasir Date: Tue, 3 Mar 2026 14:02:28 +0300 Subject: [PATCH 07/18] feat: Update role_map to use assert_capability_valid and enhance TimeLock enum definition --- .../examples/counter/sources/counter.move | 25 +++---- components_move/sources/role_map.move | 70 ++++++++----------- components_move/sources/timelock.move | 2 +- 3 files changed, 40 insertions(+), 57 deletions(-) diff --git a/components_move/examples/counter/sources/counter.move b/components_move/examples/counter/sources/counter.move index 897b1fa..a791fc5 100644 --- a/components_move/examples/counter/sources/counter.move +++ b/components_move/examples/counter/sources/counter.move @@ -12,10 +12,6 @@ use tf_components::{ role_map }; -#[error] -const EPermissionDenied: vector = - b"The role associated with the provided capability does not have the required permission"; - public struct Counter has key { id: UID, value: u64, @@ -78,18 +74,15 @@ public fun create(ctx: &mut TxContext): (Capability, ID) { (admin_cap, counter_id) } -public fun increment(counter: &mut Counter, cap: &Capability, clock: &Clock, ctx: &mut TxContext) { - assert!( - counter - .access - .is_capability_valid( - cap, - &permission::increment_counter(), - clock, - ctx, - ), - EPermissionDenied, - ); +public fun increment(counter: &mut Counter, cap: &Capability, clock: &Clock, ctx: &TxContext) { + counter + .access + .assert_capability_valid( + cap, + &permission::increment_counter(), + clock, + ctx, + ); counter.value = counter.value + 1; } diff --git a/components_move/sources/role_map.move b/components_move/sources/role_map.move index 6d156e4..72ee9b0 100644 --- a/components_move/sources/role_map.move +++ b/components_move/sources/role_map.move @@ -19,7 +19,7 @@ /// - Allows to create, delete, and update roles and their permissions /// - Allows to issue, revoke, and destroy `Capability`s associated with a specific role /// - Validates `Capability`s against the defined roles to facilitate proper access control by the integrating module -/// (function `RoleMap.is_capability_valid()`) +/// (function `RoleMap.assert_capability_valid()`) /// - All functions are access restricted by custom permissions defined during `RoleMap` instantiation /// - Stores the initial admin role name in `initial_admin_role_name` /// - Tracks active initial admin capability IDs in `initial_admin_cap_ids` @@ -98,22 +98,13 @@ public struct CapabilityRevoked has copy, drop { capability_id: ID, } -/// Emitted when a role is created -public struct RoleCreated has copy, drop { - target_key: ID, - role: String, -} - -/// Emitted when a role is removed -public struct RoleRemoved has copy, drop { - target_key: ID, - role: String, -} - -/// Emitted when a role is updated -public struct RoleUpdated has copy, drop { +/// Emitted when a role is changed. +/// +/// The action is one of: "create", "update", "delete". +public struct RoleChanged has copy, drop { target_key: ID, role: String, + action: String, } // =============== Core Types ==================== @@ -270,18 +261,19 @@ public fun create_role( role: String, permissions: VecSet

, clock: &Clock, - ctx: &mut TxContext, + ctx: &TxContext, ) { - self.is_capability_valid( + self.assert_capability_valid( cap, &self.role_admin_permissions.add, clock, ctx, ); - event::emit(RoleCreated { + event::emit(RoleChanged { target_key: self.target_key, role: copy role, + action: b"create".to_string(), }); vec_map::insert(&mut self.roles, role, permissions); @@ -293,9 +285,9 @@ public fun delete_role( cap: &Capability, role: &String, clock: &Clock, - ctx: &mut TxContext, + ctx: &TxContext, ) { - self.is_capability_valid( + self.assert_capability_valid( cap, &self.role_admin_permissions.delete, clock, @@ -305,9 +297,10 @@ public fun delete_role( assert!(*role != self.initial_admin_role_name, EInitialAdminRoleCannotBeDeleted); vec_map::remove(&mut self.roles, role); - event::emit(RoleRemoved { + event::emit(RoleChanged { target_key: self.target_key, role: *role, + action: b"delete".to_string(), }); } @@ -318,9 +311,9 @@ public fun update_role_permissions( role: &String, new_permissions: VecSet

, clock: &Clock, - ctx: &mut TxContext, + ctx: &TxContext, ) { - self.is_capability_valid( + self.assert_capability_valid( cap, &self.role_admin_permissions.update, clock, @@ -342,9 +335,10 @@ public fun update_role_permissions( vec_map::remove(&mut self.roles, role); vec_map::insert(&mut self.roles, *role, new_permissions); - event::emit(RoleUpdated { + event::emit(RoleChanged { target_key: self.target_key, role: *role, + action: b"update".to_string(), }); } @@ -378,16 +372,14 @@ public fun has_role(self: &RoleMap

, role: &String): bool { /// - 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( +/// Aborts if the capability is invalid for this RoleMap and permission. +public fun assert_capability_valid( self: &RoleMap

, cap: &Capability, permission: &P, clock: &Clock, - ctx: &mut TxContext, -): bool { + ctx: &TxContext, +) { assert!(self.target_key == cap.target_key(), ECapabilitySecurityVaultIdMismatch); let permissions = self.get_role_permissions(cap.role()); @@ -404,8 +396,6 @@ public fun is_capability_valid( let issued_to_addr = cap.issued_to().borrow(); assert!(*issued_to_addr == caller, ECapabilityIssuedToMismatch); }; - - true } /// Create a new capability @@ -426,7 +416,7 @@ public fun is_capability_valid( /// Sends a `CapabilityIssued` event upon successful creation. /// /// Errors: -/// - Aborts with any error documented by `is_capability_valid` if the provided capability fails authorization checks. +/// - Aborts with any error documented by `assert_capability_valid` if the provided capability fails authorization checks. /// - Aborts with `ERoleDoesNotExist` if the specified role does not exist in the role_map. /// - Aborts with `tf_components::capability::EValidityPeriodInconsistent` if the provided valid_from and valid_until are inconsistent. public fun new_capability( @@ -439,7 +429,7 @@ public fun new_capability( clock: &Clock, ctx: &mut TxContext, ): Capability { - self.is_capability_valid( + self.assert_capability_valid( cap, &self.capability_admin_permissions.add, clock, @@ -498,7 +488,7 @@ public fun destroy_capability(self: &mut RoleMap

, cap_to_dest /// Sends a `CapabilityRevoked` event upon successful revocation. /// /// Errors: -/// - Aborts with any error documented by `is_capability_valid` if the provided capability fails authorization checks. +/// - Aborts with any error documented by `assert_capability_valid` if the provided capability fails authorization checks. /// - Aborts with `ECapabilityNotIssued` if `cap_to_revoke` is not currently issued by this `RoleMap`. /// - Aborts with `EInitialAdminCapabilityMustBeExplicitlyDestroyed` if `cap_to_revoke` is an initial admin capability. public fun revoke_capability( @@ -506,9 +496,9 @@ public fun revoke_capability( cap: &Capability, cap_to_revoke: ID, clock: &Clock, - ctx: &mut TxContext, + ctx: &TxContext, ) { - self.is_capability_valid( + self.assert_capability_valid( cap, &self.capability_admin_permissions.revoke, clock, @@ -578,7 +568,7 @@ public fun destroy_initial_admin_capability( /// Sends a `CapabilityRevoked` event upon successful revocation. /// /// Errors: -/// - Aborts with any error documented by `is_capability_valid` if the provided capability fails authorization checks. +/// - Aborts with any error documented by `assert_capability_valid` if the provided capability fails authorization checks. /// - Aborts with `ECapabilityNotIssued` if `cap_to_revoke` is not currently issued by this `RoleMap`. /// - Aborts with `ECapabilityIsNotInitialAdmin` if `cap_to_revoke` is not an initial admin capability. public fun revoke_initial_admin_capability( @@ -586,9 +576,9 @@ public fun revoke_initial_admin_capability( cap: &Capability, cap_to_revoke: ID, clock: &Clock, - ctx: &mut TxContext, + ctx: &TxContext, ) { - self.is_capability_valid( + self.assert_capability_valid( cap, &self.capability_admin_permissions.revoke, clock, diff --git a/components_move/sources/timelock.move b/components_move/sources/timelock.move index 3333ff6..572d426 100644 --- a/components_move/sources/timelock.move +++ b/components_move/sources/timelock.move @@ -17,7 +17,7 @@ const ETimelockNotExpired: u64 = 1; /// Represents different types of time-based locks that can be applied to /// onchain objects. -public enum TimeLock has store, drop { +public enum TimeLock has drop, store { /// A lock that unlocks at a specific Unix timestamp (seconds since Unix epoch) UnlockAt(u32), /// Same as UnlockAt (unlocks at specific timestamp) but using milliseconds since Unix epoch From bb710f5fef2b8521c23e913fb0ab1d8e52309173 Mon Sep 17 00:00:00 2001 From: Yasir Date: Tue, 3 Mar 2026 15:17:24 +0300 Subject: [PATCH 08/18] feat: Update role change events to separate role creation, removal, and update actions --- components_move/Move.lock | 4 ++-- components_move/sources/role_map.move | 28 ++++++++++++++++----------- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/components_move/Move.lock b/components_move/Move.lock index 2af1373..05bd2b8 100644 --- a/components_move/Move.lock +++ b/components_move/Move.lock @@ -42,6 +42,6 @@ dependencies = [ ] [move.toolchain-version] -compiler-version = "1.16.2" +compiler-version = "1.17.2" edition = "2024.beta" -flavor = "iota" +flavor = "iota" \ No newline at end of file diff --git a/components_move/sources/role_map.move b/components_move/sources/role_map.move index 72ee9b0..05d19d8 100644 --- a/components_move/sources/role_map.move +++ b/components_move/sources/role_map.move @@ -98,13 +98,22 @@ public struct CapabilityRevoked has copy, drop { capability_id: ID, } -/// Emitted when a role is changed. -/// -/// The action is one of: "create", "update", "delete". -public struct RoleChanged has copy, drop { +/// Emitted when a role is created +public struct RoleCreated has copy, drop { + target_key: ID, + role: String, +} + +/// Emitted when a role is removed +public struct RoleRemoved has copy, drop { + target_key: ID, + role: String, +} + +/// Emitted when a role is updated +public struct RoleUpdated has copy, drop { target_key: ID, role: String, - action: String, } // =============== Core Types ==================== @@ -270,10 +279,9 @@ public fun create_role( ctx, ); - event::emit(RoleChanged { + event::emit(RoleCreated { target_key: self.target_key, role: copy role, - action: b"create".to_string(), }); vec_map::insert(&mut self.roles, role, permissions); @@ -297,10 +305,9 @@ public fun delete_role( assert!(*role != self.initial_admin_role_name, EInitialAdminRoleCannotBeDeleted); vec_map::remove(&mut self.roles, role); - event::emit(RoleChanged { + event::emit(RoleRemoved { target_key: self.target_key, role: *role, - action: b"delete".to_string(), }); } @@ -335,10 +342,9 @@ public fun update_role_permissions( vec_map::remove(&mut self.roles, role); vec_map::insert(&mut self.roles, *role, new_permissions); - event::emit(RoleChanged { + event::emit(RoleUpdated { target_key: self.target_key, role: *role, - action: b"update".to_string(), }); } From bd55db011abf83945ef67288082a4ad1dc931367 Mon Sep 17 00:00:00 2001 From: Yasir Date: Tue, 3 Mar 2026 16:09:24 +0300 Subject: [PATCH 09/18] feat: Simplify CapabilityDestroyed struct and update event emission for destroyed capabilities --- components_move/sources/role_map.move | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/components_move/sources/role_map.move b/components_move/sources/role_map.move index 05d19d8..f8b75e1 100644 --- a/components_move/sources/role_map.move +++ b/components_move/sources/role_map.move @@ -86,13 +86,9 @@ public struct CapabilityIssued has copy, drop { public struct CapabilityDestroyed has copy, drop { target_key: ID, capability_id: ID, - role: String, - issued_to: Option

, - valid_from: Option, - valid_until: Option, } -/// Emitted when a capability is revoked or destroyed +/// Emitted when a capability is revoked public struct CapabilityRevoked has copy, drop { target_key: ID, capability_id: ID, @@ -477,10 +473,6 @@ public fun destroy_capability(self: &mut RoleMap

, cap_to_dest event::emit(CapabilityDestroyed { target_key: self.target_key, 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(); @@ -554,10 +546,6 @@ public fun destroy_initial_admin_capability( event::emit(CapabilityDestroyed { target_key: self.target_key, 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(); From e6518195ef936575f09c53ac5d820f13f427235b Mon Sep 17 00:00:00 2001 From: Christof Gerritsma Date: Mon, 9 Mar 2026 14:13:25 +0100 Subject: [PATCH 10/18] RoleMap with generic role-data argument (#100) Generic role-data argument for the TfComponents `RoleMap` struct. The usage of role-data is demonstrated in the accompanying counter example (`components_move/examples/counter/sources/counter.move`) and counter test (components_move/examples/counter/tests/counter_tests.move). --- .../examples/counter/sources/counter.move | 135 +++++++++-- .../examples/counter/tests/counter_tests.move | 72 +++++- components_move/sources/role_map.move | 223 +++++++++++++----- components_move/tests/core_test_utils.move | 2 +- components_move/tests/role_map_tests.move | 59 ++++- 5 files changed, 393 insertions(+), 98 deletions(-) diff --git a/components_move/examples/counter/sources/counter.move b/components_move/examples/counter/sources/counter.move index a791fc5..a9d6efd 100644 --- a/components_move/examples/counter/sources/counter.move +++ b/components_move/examples/counter/sources/counter.move @@ -1,21 +1,76 @@ // Copyright (c) 2026 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -/// Simple shared counter to demonstrate role_mao::RoleMap integration +/// Simple shared counter to demonstrate role_map::RoleMap integration +/// It also demonstrates how to use the generic role-data argument of the RoleMap +/// to implement time-based permissions by storing a weekday in the role data +/// and checking if the current day matches the stored weekday in the permission check. #[test_only] module tf_components::counter; use iota::clock::Clock; -use tf_components::{ - capability::Capability, - counter_permission::{Self as permission, CounterPermission}, - role_map -}; +use tf_components::capability::Capability; +use tf_components::counter_permission::{Self as permission, CounterPermission}; +use tf_components::role_map; + +#[error] +const EWeekDayMismatch: vector = + b"The role associated with the provided capability is restricted to a specific weekday which does not match the current weekday"; + +public enum Weekday has copy, drop, store { + Monday, + Tuesday, + Wednesday, + Thursday, + Friday, + Saturday, + Sunday, +} + +public fun weekday_to_u8(self: &Weekday): u8 { + match (self) { + Weekday::Monday => 0, + Weekday::Tuesday => 1, + Weekday::Wednesday => 2, + Weekday::Thursday => 3, + Weekday::Friday => 4, + Weekday::Saturday => 5, + Weekday::Sunday => 6, + } +} + +public fun monday(): Weekday { + Weekday::Monday +} + +public fun tuesday(): Weekday { + Weekday::Tuesday +} + +public fun wednesday(): Weekday { + Weekday::Wednesday +} + +public fun thursday(): Weekday { + Weekday::Thursday +} + +public fun friday(): Weekday { + Weekday::Friday +} + +public fun saturday(): Weekday { + Weekday::Saturday +} + +public fun sunday(): Weekday { + Weekday::Sunday +} public struct Counter has key { id: UID, value: u64, - access: role_map::RoleMap, + access: role_map::RoleMap, } public fun create(ctx: &mut TxContext): (Capability, ID) { @@ -74,26 +129,60 @@ public fun create(ctx: &mut TxContext): (Capability, ID) { (admin_cap, counter_id) } -public fun increment(counter: &mut Counter, cap: &Capability, clock: &Clock, ctx: &TxContext) { - counter - .access - .assert_capability_valid( - cap, - &permission::increment_counter(), - clock, - ctx, - ); - counter.value = counter.value + 1; +public fun increment(self: &mut Counter, cap: &Capability, clock: &Clock, ctx: &TxContext) { + self.assert_capability_valid( + cap, + &permission::increment_counter(), + clock, + ctx, + ); + self.value = self.value + 1; +} + +public fun assert_capability_valid( + self: &Counter, + cap: &Capability, + permission: &CounterPermission, + clock: &Clock, + ctx: &TxContext, +): bool { + self.access.assert_capability_valid( + cap, + permission, + clock, + ctx + ); + let role_data_option = self.access.get_role_data(cap.role()); + if (role_data_option.is_some_and!(|required_weekday| { + let current_weekday = to_weekday(clock); + weekday_to_u8(required_weekday) != current_weekday + })) { + assert!(false, EWeekDayMismatch); + }; + true +} + +public fun access(self: &Counter): &role_map::RoleMap { + &self.access } -public fun access(counter: &Counter): &role_map::RoleMap { - &counter.access +public fun access_mut(self: &mut Counter): &mut role_map::RoleMap { + &mut self.access } -public fun access_mut(counter: &mut Counter): &mut role_map::RoleMap { - &mut counter.access +public fun value(self: &Counter): u64 { + self.value } -public fun value(counter: &Counter): u64 { - counter.value +/// Returns the day of the week (0 = Monday, 1 = Tuesday, ..., 6 = Sunday) +/// based on a millisecond Unix timestamp. +/// The Unix epoch (timestamp 0) was a Thursday (day index 3). +public fun to_weekday(clock: &Clock): u8 { + let timestamp_ms = clock.timestamp_ms(); + let ms_per_day: u64 = 86_400_000; // 24 * 60 * 60 * 1000 + let day_count = timestamp_ms / ms_per_day; + // Unix epoch is Thursday. If Monday = 0, then Thursday = 3. + // So we add 3 to shift the epoch day to Thursday, then mod 7. + let weekday = ((day_count + 3) % 7) as u8; + weekday } diff --git a/components_move/examples/counter/tests/counter_tests.move b/components_move/examples/counter/tests/counter_tests.move index eb1273d..637c4f2 100644 --- a/components_move/examples/counter/tests/counter_tests.move +++ b/components_move/examples/counter/tests/counter_tests.move @@ -12,12 +12,13 @@ use tf_components::{ counter_permission as permission }; -/// Test capability lifecycle: creation, usage, revocation and destruction in a complete workflow. -#[test] -fun test_capability_lifecycle() { - let super_admin_user = @0xAD; - let counter_admin_user = @0xB0B; - +/// Creates a Counter with a "counter-admin" role restricted to Wednesday, +/// issues a capability for that role to `counter_admin_user`, and returns +/// the scenario along with the issued capability's ID. +fun prepare_counter_and_issue_capability( + super_admin_user: address, + counter_admin_user: address, +): (ts::Scenario, ID) { let mut scenario = ts::begin(super_admin_user); // Setup: Create Counter @@ -26,7 +27,7 @@ fun test_capability_lifecycle() { transfer::public_transfer(super_admin_cap, super_admin_user); }; - // Create an additional CounterAdmin role + // Create an additional CounterAdmin role only valid on Wednesday ts::next_tx(&mut scenario, super_admin_user); { let super_admin_cap = ts::take_from_sender(&scenario); @@ -42,6 +43,7 @@ fun test_capability_lifecycle() { &super_admin_cap, string::utf8(b"counter-admin"), permission::counter_admin_permissions(), + std::option::some(counter::wednesday()), &clock, ts::ctx(&mut scenario), ); @@ -82,12 +84,28 @@ fun test_capability_lifecycle() { counter_admin_cap_id }; - // Use CounterAdmin capability to increment the counter + (scenario, counter_admin_cap_id) +} + +/// Test capability lifecycle: creation, usage, revocation and destruction in a complete workflow. +#[test] +fun test_capability_lifecycle() { + let super_admin_user = @0xAD; + let counter_admin_user = @0xB0B; + + let (mut scenario, counter_admin_cap_id) = prepare_counter_and_issue_capability( + super_admin_user, + counter_admin_user, + ); + + // Use CounterAdmin capability on Wednesday to increment the counter ts::next_tx(&mut scenario, counter_admin_user); { let counter_admin_cap = ts::take_from_sender(&scenario); let mut counter = ts::take_shared(&scenario); - let clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); + let mut clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); + let ms_per_day: u64 = 86_400_000; + clock.set_for_testing(ms_per_day * 6 + 1); // Set to the first ms on the first Wednesday after Unix epoch which happened on a Thursday assert!(counter.value() == 0, 3); counter.increment( @@ -142,3 +160,39 @@ fun test_capability_lifecycle() { ts::end(scenario); } + +/// Test that a capability associated with a role restricted to Wednesday cannot be used on Monday. +#[test] +#[expected_failure(abort_code = counter::EWeekDayMismatch)] +fun test_wednesday_role_rejected_on_monday() { + let super_admin_user = @0xAD; + let counter_admin_user = @0xB0B; + let ms_per_day: u64 = 86_400_000; + + let (mut scenario, _counter_admin_cap_id) = prepare_counter_and_issue_capability( + super_admin_user, + counter_admin_user, + ); + + // Attempt to use the capability on Monday — should fail with EWeekDayMismatch + ts::next_tx(&mut scenario, counter_admin_user); + { + let counter_admin_cap = ts::take_from_sender(&scenario); + let mut counter = ts::take_shared(&scenario); + let mut clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); + // Day 4 from epoch = Monday (epoch day 0 = Thursday(3), +4 days = Monday(0)) + clock.set_for_testing(ms_per_day * 4 + 1); + + counter.increment( + &counter_admin_cap, + &clock, + ts::ctx(&mut scenario), + ); + + iota::clock::destroy_for_testing(clock); + ts::return_to_sender(&scenario, counter_admin_cap); + ts::return_shared(counter); + }; + + ts::end(scenario); +} diff --git a/components_move/sources/role_map.move b/components_move/sources/role_map.move index f8b75e1..d31971d 100644 --- a/components_move/sources/role_map.move +++ b/components_move/sources/role_map.move @@ -12,7 +12,7 @@ /// The final design and API of these modules will be released as part of the Audit Trail product, which will be /// the first product to integrate these components. /// -/// A `RoleMap

` provides the following functionalities: +/// A `RoleMap` provides the following functionalities: /// - Uses custom permission-types, defined by the integrating module, using the generic argument `P` /// - Defines an initial role with a custom set of permissions (i.e. for an Admin role) and creates an initial /// `Capability` for this role to allow later access control administration by the creator of the integrating module @@ -21,6 +21,8 @@ /// - Validates `Capability`s against the defined roles to facilitate proper access control by the integrating module /// (function `RoleMap.assert_capability_valid()`) /// - All functions are access restricted by custom permissions defined during `RoleMap` instantiation +/// - Using the generic argument `D`, custom role-data can be stored as part of each role definition, to allow extended +/// access authorization by modules integrating the RoleMap /// - Stores the initial admin role name in `initial_admin_role_name` /// - Tracks active initial admin capability IDs in `initial_admin_cap_ids` /// - Requires explicit initial-admin revoke/destroy APIs for those IDs @@ -31,7 +33,10 @@ /// module tf_components::role_map; -use iota::{clock::Clock, event, vec_map::{Self, VecMap}, vec_set::{Self, VecSet}}; +use iota::clock::{Self, Clock}; +use iota::event; +use iota::vec_map::{Self, VecMap}; +use iota::vec_set::{Self, VecSet}; use std::string::String; use tf_components::capability::{Self, Capability}; @@ -86,6 +91,10 @@ public struct CapabilityIssued has copy, drop { public struct CapabilityDestroyed has copy, drop { target_key: ID, capability_id: ID, + role: String, + issued_to: Option

, + valid_from: Option, + valid_until: Option, } /// Emitted when a capability is revoked @@ -95,21 +104,31 @@ public struct CapabilityRevoked has copy, drop { } /// Emitted when a role is created -public struct RoleCreated has copy, drop { +public struct RoleCreated has copy, drop { target_key: ID, role: String, + permissions: VecSet

, + data: Option, + created_by: address, + timestamp: u64, } -/// Emitted when a role is removed -public struct RoleRemoved has copy, drop { +/// Emitted when a role is deleted +public struct RoleDeleted has copy, drop { target_key: ID, role: String, + deleted_by: address, + timestamp: u64, } -/// Emitted when a role is updated -public struct RoleUpdated has copy, drop { +/// Emitted when a role's is updated +public struct RoleUpdated has copy, drop { target_key: ID, role: String, + new_permissions: VecSet

, + new_data: Option, + updated_by: address, + timestamp: u64, } // =============== Core Types ==================== @@ -132,16 +151,23 @@ public struct CapabilityAdminPermissions has copy, drop, store { 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. tf_components::CounterPermission or audit_trail::Permission) -public struct RoleMap has copy, drop, store { +/// The RoleMap structure mapping role names to their associated permissions and role-data +/// +/// Generic parameters: +/// * P defines the permission type used by the integrating module +/// (i.e. audit_trail::Permission) +/// * D defines the role-data type. Each role has role-data which can be used by integrating modules to provide +/// explanations or to perform additional access control constraints, performed by additional access control checks. +/// To perform additional access control checks, integrating modules need to wrap the `RoleMap::is_capability_valid()` call +/// in their own `is_capability_valid()` implementation, use this wrapper function for evaluating the additional checks +/// and use the role-data to store role specific variables. `RoleMap::is_capability_valid()` itself will ignore the role-data. +public struct RoleMap has copy, drop, store { /// Identifies the scope (or domain) managed by the RoleMap. Usually this is the ID of the managed onchain object /// (i.e. an audit trail). You can also derive an arbitrary ID value reused by several managed onchain objects /// to share the used roles and capabilities between these objects. target_key: ID, /// Mapping of role names to their associated permissions - roles: VecMap>, + roles: VecMap>, /// Name of the initial admin role created by `new`. /// The RoleMap uses this to protect that role from unsafe changes. initial_admin_role_name: String, @@ -157,6 +183,13 @@ public struct RoleMap has copy, drop, store { capability_admin_permissions: CapabilityAdminPermissions

, } +// Definition of role specific access permissions and role-data +// See `RoleMap` above for more details +public struct Role has copy, drop, store { + permissions: VecSet

, + data: Option, +} + // ========== Role & Capability AdminPermissions Functions =========== public fun new_role_admin_permissions( @@ -207,14 +240,14 @@ public fun new_capability_admin_permissions( /// - Aborts with `EInitialAdminPermissionsInconsistent` if `initial_admin_role_permissions` /// does not include all permissions configured in `role_admin_permissions` and /// `capability_admin_permissions`. -public fun new( +public fun new( target_key: ID, initial_admin_role_name: String, initial_admin_role_permissions: VecSet

, role_admin_permissions: RoleAdminPermissions

, capability_admin_permissions: CapabilityAdminPermissions

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

, Capability) { +): (RoleMap, Capability) { assert!( has_required_admin_permissions( &initial_admin_role_permissions, @@ -224,8 +257,11 @@ public fun new( EInitialAdminPermissionsInconsistent, ); - let mut roles = vec_map::empty>(); - roles.insert(copy initial_admin_role_name, initial_admin_role_permissions); + let mut roles = vec_map::empty>(); + roles.insert( + copy initial_admin_role_name, + new_role(initial_admin_role_permissions, std::option::none()), + ); let admin_cap = capability::new_capability( copy initial_admin_role_name, @@ -253,18 +289,36 @@ public fun new( } /// Get the permissions associated with a specific role. -/// Aborts with ERoleDoesNotExist if the role does not exist. -public fun get_role_permissions(self: &RoleMap

, role: &String): &VecSet

{ +/// Aborts with `ERoleDoesNotExist` if the role does not exist. +public fun get_role_permissions( + self: &RoleMap, + role: &String, +): &VecSet

{ + assert!(vec_map::contains(&self.roles, role), ERoleDoesNotExist); + &vec_map::get(&self.roles, role).permissions +} + +/// Get the role-data associated with a specific role. +/// Aborts with `ERoleDoesNotExist` if the role does not exist. +public fun get_role_data( + self: &RoleMap, + role: &String, +): &Option { assert!(vec_map::contains(&self.roles, role), ERoleDoesNotExist); - vec_map::get(&self.roles, role) + &vec_map::get(&self.roles, role).data } /// Create a new role consisting of a role name and associated permissions -public fun create_role( - self: &mut RoleMap

, +/// - Aborts with any error documented by `assert_capability_valid` if the provided capability fails authorization checks. +/// - The provided capability needs to grant the `RoleAdminPermissions::add` permission. +/// +/// Sends a `RoleCreated` event upon successful update. +public fun create_role( + self: &mut RoleMap, cap: &Capability, role: String, permissions: VecSet

, + data: Option, clock: &Clock, ctx: &TxContext, ) { @@ -275,17 +329,26 @@ public fun create_role( ctx, ); + vec_map::insert(&mut self.roles, role, new_role(permissions, data)); + event::emit(RoleCreated { target_key: self.target_key, - role: copy role, + role, + permissions, + data, + created_by: ctx.sender(), + timestamp: clock::timestamp_ms(clock), }); - - vec_map::insert(&mut self.roles, role, permissions); } /// Delete an existing role -public fun delete_role( - self: &mut RoleMap

, +/// - Aborts with any error documented by `assert_capability_valid` if the provided capability fails authorization checks. +/// - The provided capability needs to grant the `RoleAdminPermissions::delete` permission. +/// - Aborts with `ERoleDoesNotExist` if the specified role does not exist in the role_map. +/// +/// Sends a `RoleDeleted` event upon successful update. +public fun delete_role( + self: &mut RoleMap, cap: &Capability, role: &String, clock: &Clock, @@ -298,21 +361,33 @@ public fun delete_role( ctx, ); + assert!(vec_map::contains(&self.roles, role), ERoleDoesNotExist); assert!(*role != self.initial_admin_role_name, EInitialAdminRoleCannotBeDeleted); vec_map::remove(&mut self.roles, role); - event::emit(RoleRemoved { + event::emit(RoleDeleted { target_key: self.target_key, role: *role, + deleted_by: ctx.sender(), + timestamp: clock::timestamp_ms(clock), }); } -/// Update permissions associated with an existing role -public fun update_role_permissions( - self: &mut RoleMap

, +/// Update permissions and role_data associated with an existing role +/// - Aborts with any error documented by `assert_capability_valid` if the provided capability fails authorization checks. +/// - The provided capability needs to grant the `RoleAdminPermissions::update` permission. +/// - Aborts with `ERoleDoesNotExist` if the specified role does not exist in the role_map. +/// - Aborts with `EInitialAdminPermissionsInconsistent` if `new_permissions` +/// does not include all permissions configured in `role_admin_permissions` and +/// `capability_admin_permissions`. +/// +/// Sends a `RoleUpdated` event upon successful update. +public fun update_role( + self: &mut RoleMap, cap: &Capability, - role: &String, + role_name: &String, new_permissions: VecSet

, + data: Option, clock: &Clock, ctx: &TxContext, ) { @@ -323,7 +398,7 @@ public fun update_role_permissions( ctx, ); - if (*role == self.initial_admin_role_name) { + if (*role_name == self.initial_admin_role_name) { assert!( has_required_admin_permissions( &new_permissions, @@ -334,21 +409,37 @@ public fun update_role_permissions( ); }; - assert!(vec_map::contains(&self.roles, role), ERoleDoesNotExist); - vec_map::remove(&mut self.roles, role); - vec_map::insert(&mut self.roles, *role, new_permissions); + assert!(vec_map::contains(&self.roles, role_name), ERoleDoesNotExist); + let role = vec_map::get_mut(&mut self.roles, role_name); + + role.permissions = new_permissions; + role.data = data; event::emit(RoleUpdated { target_key: self.target_key, - role: *role, + role: *role_name, + new_permissions, + new_data: data, + updated_by: ctx.sender(), + timestamp: clock::timestamp_ms(clock), }); } /// Indicates if the specified role exists in the role_map -public fun has_role(self: &RoleMap

, role: &String): bool { +public fun has_role(self: &RoleMap, role: &String): bool { vec_map::contains(&self.roles, role) } +public(package) fun new_role( + permissions: VecSet

, + data: Option, +): Role { + Role { + permissions, + data, + } +} + /// ===== Capability Functions ======= /// Indicates if a provided capability is valid. /// @@ -375,8 +466,8 @@ public fun has_role(self: &RoleMap

, role: &String): bool { /// - ctx: Reference to the transaction context for accessing the caller's address. /// /// Aborts if the capability is invalid for this RoleMap and permission. -public fun assert_capability_valid( - self: &RoleMap

, +public fun assert_capability_valid( + self: &RoleMap, cap: &Capability, permission: &P, clock: &Clock, @@ -404,7 +495,7 @@ public fun assert_capability_valid( /// /// Parameters /// ---------- -/// - role_map: Reference to the `RoleMap` mapping. +/// - self: 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. @@ -415,14 +506,15 @@ public fun assert_capability_valid( /// /// Returns the newly created capability. /// -/// Sends a `CapabilityIssued` event upon successful creation. -/// /// Errors: /// - Aborts with any error documented by `assert_capability_valid` if the provided capability fails authorization checks. +/// - The provided capability needs to grant the `CapabilityAdminPermissions::add` permission. /// - Aborts with `ERoleDoesNotExist` if the specified role does not exist in the role_map. /// - Aborts with `tf_components::capability::EValidityPeriodInconsistent` if the provided valid_from and valid_until are inconsistent. -public fun new_capability( - self: &mut RoleMap

, +/// +/// Sends a `CapabilityIssued` event upon successful creation. +public fun new_capability( + self: &mut RoleMap, cap: &Capability, role: &String, issued_to: Option

, @@ -447,7 +539,7 @@ public fun new_capability( valid_until, ctx, ); - issue_capability(self, &new_cap); + self.issue_capability(&new_cap); new_cap } @@ -459,7 +551,7 @@ public fun new_capability( /// Use `destroy_initial_admin_capability` instead. /// /// Sends a `CapabilityDestroyed` event upon successful destruction. -public fun destroy_capability(self: &mut RoleMap

, cap_to_destroy: Capability) { +public fun destroy_capability(self: &mut RoleMap, cap_to_destroy: Capability) { assert!(self.target_key == cap_to_destroy.target_key(), ECapabilitySecurityVaultIdMismatch); assert!( !self.initial_admin_cap_ids.contains(&cap_to_destroy.id()), @@ -473,7 +565,11 @@ public fun destroy_capability(self: &mut RoleMap

, cap_to_dest event::emit(CapabilityDestroyed { target_key: self.target_key, 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(); } @@ -487,10 +583,11 @@ public fun destroy_capability(self: &mut RoleMap

, cap_to_dest /// /// Errors: /// - Aborts with any error documented by `assert_capability_valid` if the provided capability fails authorization checks. +/// - The provided capability needs to grant the `CapabilityAdminPermissions::revoke` permission. /// - Aborts with `ECapabilityNotIssued` if `cap_to_revoke` is not currently issued by this `RoleMap`. /// - Aborts with `EInitialAdminCapabilityMustBeExplicitlyDestroyed` if `cap_to_revoke` is an initial admin capability. -public fun revoke_capability( - self: &mut RoleMap

, +public fun revoke_capability( + self: &mut RoleMap, cap: &Capability, cap_to_revoke: ID, clock: &Clock, @@ -530,8 +627,8 @@ public fun revoke_capability( /// Errors: /// - Aborts with `ECapabilitySecurityVaultIdMismatch` if the capability's target_key does not match. /// - Aborts with `ECapabilityIsNotInitialAdmin` if the capability is not an initial admin capability. -public fun destroy_initial_admin_capability( - self: &mut RoleMap

, +public fun destroy_initial_admin_capability( + self: &mut RoleMap, cap_to_destroy: Capability, ) { assert!(self.target_key == cap_to_destroy.target_key(), ECapabilitySecurityVaultIdMismatch); @@ -546,7 +643,11 @@ public fun destroy_initial_admin_capability( event::emit(CapabilityDestroyed { target_key: self.target_key, 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(); } @@ -565,8 +666,8 @@ public fun destroy_initial_admin_capability( /// - Aborts with any error documented by `assert_capability_valid` if the provided capability fails authorization checks. /// - Aborts with `ECapabilityNotIssued` if `cap_to_revoke` is not currently issued by this `RoleMap`. /// - Aborts with `ECapabilityIsNotInitialAdmin` if `cap_to_revoke` is not an initial admin capability. -public fun revoke_initial_admin_capability( - self: &mut RoleMap

, +public fun revoke_initial_admin_capability( + self: &mut RoleMap, cap: &Capability, cap_to_revoke: ID, clock: &Clock, @@ -607,7 +708,7 @@ fun has_required_admin_permissions( } /// Issues a new capability -fun issue_capability(self: &mut RoleMap

, new_cap: &Capability) { +fun issue_capability(self: &mut RoleMap, new_cap: &Capability) { self.issued_capabilities.insert(new_cap.id()); if (new_cap.role() == &self.initial_admin_role_name) { self.initial_admin_cap_ids.insert(new_cap.id()); @@ -626,20 +727,22 @@ fun issue_capability(self: &mut RoleMap

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

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

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

): &RoleAdminPermissions

{ +// Returns the role admin permissions associated with the role_map +public fun role_admin_permissions( + self: &RoleMap, +): &RoleAdminPermissions

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

): &VecSet { +public fun issued_capabilities(self: &RoleMap): &VecSet { &self.issued_capabilities } diff --git a/components_move/tests/core_test_utils.move b/components_move/tests/core_test_utils.move index 5cca190..39dfa3e 100644 --- a/components_move/tests/core_test_utils.move +++ b/components_move/tests/core_test_utils.move @@ -99,7 +99,7 @@ public fun initial_admin_role_name(): String { /// Returns the RoleMap, admin capability, and the target_key public fun create_test_role_map( ctx: &mut iota::tx_context::TxContext, -): (tf_components::role_map::RoleMap, tf_components::capability::Capability, ID) { +): (tf_components::role_map::RoleMap, tf_components::capability::Capability, ID) { let target_key = fake_object_id_from_string(&SECURITY_VAULT_ID_STRING.to_string()); let initial_admin_role = INITIAL_ADMIN_ROLE_NAME.to_string(); let (role_admin_permissions, capability_admin_permissions) = get_admin_permissions(); diff --git a/components_move/tests/role_map_tests.move b/components_move/tests/role_map_tests.move index a7d0e21..5fdface 100644 --- a/components_move/tests/role_map_tests.move +++ b/components_move/tests/role_map_tests.move @@ -4,6 +4,8 @@ #[test_only] module tf_components::role_map_tests; +use std::string::String; + use iota::{test_scenario as ts, vec_set}; use std::string; use tf_components::{core_test_utils as test_utils, role_map}; @@ -25,7 +27,7 @@ fun test_role_based_permission_delegation() { // Step 1: admin_user creates the audit trail let (mut role_map, admin_cap) = { - let (role_map, admin_cap) = role_map::new( + let (role_map, admin_cap) = role_map::new<_, String>( target_key, initial_admin_role_name, test_utils::super_admin_permissions(), @@ -46,6 +48,9 @@ fun test_role_based_permission_delegation() { { let clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); + let role_admin_data = b"RoleAdmin role data".to_string(); + let cap_admin_data = b"CapAdmin role data".to_string(); + // Verify initial state - should only have the initial admin role assert!(role_map.size() == 1, 2); @@ -54,6 +59,7 @@ fun test_role_based_permission_delegation() { &admin_cap, string::utf8(b"RoleAdmin"), vec_set::singleton(test_utils::manage_roles()), + std::option::some(role_admin_data), &clock, ts::ctx(&mut scenario), ); @@ -63,14 +69,54 @@ fun test_role_based_permission_delegation() { &admin_cap, string::utf8(b"CapAdmin"), vec_set::singleton(test_utils::manage_capabilities()), + std::option::some(cap_admin_data), &clock, ts::ctx(&mut scenario), ); // Verify both roles were created assert!(role_map.size() == 3, 3); // Initial admin + RoleAdmin + CapAdmin - assert!(role_map.has_role(&string::utf8(b"RoleAdmin")), 4); - assert!(role_map.has_role(&string::utf8(b"CapAdmin")), 5); + assert!(role_map.has_role(&b"RoleAdmin".to_string()), 4); + assert!(role_map.has_role(&b"CapAdmin".to_string()), 5); + assert!(role_map.get_role_data(&b"RoleAdmin".to_string()) == std::option::some(role_admin_data), 6); + assert!(role_map.get_role_data(&b"CapAdmin".to_string()) == std::option::some(cap_admin_data), 7); + + iota::clock::destroy_for_testing(clock); + }; + + // Step 3: Admin updates RoleAdmin and CapAdmin roles + ts::next_tx(&mut scenario, admin_user); + { + let clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); + + let updated_role_admin_data = b"Updated RoleAdmin role data".to_string(); + let updated_cap_admin_data = b"Updated CapAdmin role data".to_string(); + + // Update RoleAdmin role permissions and data - for simplicity, we swap the permissions to each other's permissions + role_map.update_role( + &admin_cap, + &b"RoleAdmin".to_string(), + vec_set::singleton(test_utils::manage_capabilities()), + std::option::some(updated_role_admin_data), + &clock, + ts::ctx(&mut scenario), + ); + + // Update CapAdmin role - for simplicity, we swap the permissions to each other's permissions + role_map.update_role( + &admin_cap, + &b"CapAdmin".to_string(), + vec_set::singleton(test_utils::manage_roles()), + std::option::some(updated_cap_admin_data), + &clock, + ts::ctx(&mut scenario), + ); + + // Verify both roles were updated + assert!(role_map.get_role_data(&b"RoleAdmin".to_string()) == std::option::some(updated_role_admin_data), 8); + assert!(role_map.get_role_data(&b"CapAdmin".to_string()) == std::option::some(updated_cap_admin_data), 9); + assert!(role_map.get_role_permissions(&b"RoleAdmin".to_string()).contains(&test_utils::manage_capabilities()), 10); + assert!(role_map.get_role_permissions(&b"CapAdmin".to_string()).contains(&test_utils::manage_roles()), 11); iota::clock::destroy_for_testing(clock); }; @@ -99,7 +145,7 @@ fun test_new_fails_with_empty_initial_admin_permissions() { let empty_permissions = vec_set::empty(); - let (mut role_map, admin_cap) = role_map::new( + let (mut role_map, admin_cap) = role_map::new<_, String>( target_key, b"SuperAdmin".to_string(), empty_permissions, @@ -150,10 +196,11 @@ fun test_update_initial_admin_role_removing_required_permissions_fails() { let clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); let initial_role = test_utils::initial_admin_role_name(); - role_map.update_role_permissions( + role_map.update_role( &admin_cap, &initial_role, vec_set::singleton(test_utils::manage_roles()), + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -448,6 +495,7 @@ fun test_destroy_initial_admin_capability_rejects_non_admin_cap() { &admin_cap, string::utf8(b"Reader"), vec_set::singleton(test_utils::manage_roles()), + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -485,6 +533,7 @@ fun test_revoke_initial_admin_capability_rejects_non_admin_cap() { &admin_cap, string::utf8(b"Reader"), vec_set::singleton(test_utils::manage_roles()), + std::option::none(), &clock, ts::ctx(&mut scenario), ); From 9579d8ac0967236074092905ae8039a9b80f01d0 Mon Sep 17 00:00:00 2001 From: Christof Gerritsma Date: Thu, 19 Mar 2026 17:32:39 +0100 Subject: [PATCH 11/18] Feat: `revoked_capabilities` list for TfComponents RoleMap (#108) RoleMap uses a `revoked_capabilities` deny list instead of an `issued_capabilities` list now. Including * Unit tests for `revoked_capabilities` deny list edge cases and the new `cleanup_revoked_capabilities()` function * Enhanced documentation for the `revoked_capabilities` denylist Co-authored-by: Yasir --- .../examples/counter/tests/counter_tests.move | 18 +- components_move/sources/role_map.move | 256 ++++- .../tests/capability_component_tests.move | 21 +- components_move/tests/role_map_tests.move | 883 +++++++++++++++++- 4 files changed, 1102 insertions(+), 76 deletions(-) diff --git a/components_move/examples/counter/tests/counter_tests.move b/components_move/examples/counter/tests/counter_tests.move index 637c4f2..d042ab8 100644 --- a/components_move/examples/counter/tests/counter_tests.move +++ b/components_move/examples/counter/tests/counter_tests.move @@ -6,6 +6,7 @@ module tf_components::example_counter_tests; use iota::test_scenario as ts; use std::string; +use std::option::none; use tf_components::{ capability::Capability, counter::{Self, Counter}, @@ -34,9 +35,6 @@ fun prepare_counter_and_issue_capability( let mut counter = ts::take_shared(&scenario); let clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); - // Initially only the super-admin cap should be tracked - assert!(counter.access().issued_capabilities().size() == 1, 0); - counter .access_mut() .create_role( @@ -73,10 +71,6 @@ fun prepare_counter_and_issue_capability( let counter_admin_cap_id = object::id(&counter_cap); transfer::public_transfer(counter_cap, counter_admin_user); - // Verify all capabilities are tracked - assert!(counter.access().issued_capabilities().size() == 2, 1); // super-admin + counter-admin - assert!(counter.access().issued_capabilities().contains(&counter_admin_cap_id), 2); - iota::clock::destroy_for_testing(clock); ts::return_to_sender(&scenario, super_admin_cap); ts::return_shared(counter); @@ -127,18 +121,22 @@ fun test_capability_lifecycle() { let mut counter = ts::take_shared(&scenario); let clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); + // Make sure there are no revoked capabilities so far + assert!(counter.access().revoked_capabilities().length() == 0, 0); + counter .access_mut() .revoke_capability( &super_admin_cap, counter_admin_cap_id, + none(), &clock, ts::ctx(&mut scenario), ); - // Verify capability was removed from the issued_capabilities list - assert!(counter.access().issued_capabilities().size() == 1, 5); // super-admin only - assert!(!counter.access().issued_capabilities().contains(&counter_admin_cap_id), 6); + // Verify capability has been added to the revoked_capabilities list + assert!(counter.access().revoked_capabilities().length() == 1, 1); // counter-admin only + assert!(counter.access().revoked_capabilities().contains(counter_admin_cap_id), 2); iota::clock::destroy_for_testing(clock); ts::return_to_sender(&scenario, super_admin_cap); diff --git a/components_move/sources/role_map.move b/components_move/sources/role_map.move index d31971d..fa2871b 100644 --- a/components_move/sources/role_map.move +++ b/components_move/sources/role_map.move @@ -17,15 +17,16 @@ /// - Defines an initial role with a custom set of permissions (i.e. for an Admin role) and creates an initial /// `Capability` for this role to allow later access control administration by the creator of the integrating module /// - Allows to create, delete, and update roles and their permissions -/// - Allows to issue, revoke, and destroy `Capability`s associated with a specific role +/// - Allows to issue, revoke, and destroy `Capability`s associated with a specific role (see function +/// `revoke_capability()` for more details) /// - Validates `Capability`s against the defined roles to facilitate proper access control by the integrating module /// (function `RoleMap.assert_capability_valid()`) /// - All functions are access restricted by custom permissions defined during `RoleMap` instantiation /// - Using the generic argument `D`, custom role-data can be stored as part of each role definition, to allow extended -/// access authorization by modules integrating the RoleMap +/// access authorization by modules integrating the RoleMap /// - Stores the initial admin role name in `initial_admin_role_name` /// - Tracks active initial admin capability IDs in `initial_admin_cap_ids` -/// - Requires explicit initial-admin revoke/destroy APIs for those IDs +/// - Provides explicit initial-admin revoke/destroy functions for those IDs /// /// Examples: /// - The TF product Audit Trails uses `RoleMap` to manage access to the audit trail records and their operations. @@ -37,6 +38,7 @@ use iota::clock::{Self, Clock}; use iota::event; use iota::vec_map::{Self, VecMap}; use iota::vec_set::{Self, VecSet}; +use iota::linked_table::{Self, LinkedTable}; use std::string::String; use tf_components::capability::{Self, Capability}; @@ -49,7 +51,7 @@ const ERoleDoesNotExist: vector = const ECapabilityHasBeenRevoked: vector = b"The provided capability has been revoked and is no longer valid"; #[error] -const ECapabilitySecurityVaultIdMismatch: vector = +const ECapabilityTargetKeyMismatch: vector = b"The target_key associated with the provided capability does not match the target_key of the `RoleMap`"; #[error] const ECapabilityTimeConstraintsNotMet: vector = @@ -61,8 +63,8 @@ const ECapabilityIssuedToMismatch: vector = const ECapabilityPermissionDenied: vector = b"The role associated with provided capability does not have the required permission"; #[error] -const ECapabilityNotIssued: vector = - b"The specified capability is not currently issued by this `RoleMap`"; +const ECapabilityToRevokeHasAlreadyBeenRevoked: vector = + b"The capability that shall be revoked has already been revoked"; #[error] const EInitialAdminPermissionsInconsistent: vector = b"The initial admin role must include all configured role and capability admin permissions"; @@ -101,6 +103,7 @@ public struct CapabilityDestroyed has copy, drop { public struct CapabilityRevoked has copy, drop { target_key: ID, capability_id: ID, + valid_until: u64, } /// Emitted when a role is created @@ -121,7 +124,7 @@ public struct RoleDeleted has copy, drop { timestamp: u64, } -/// Emitted when a role's is updated +/// Emitted when a role is updated public struct RoleUpdated has copy, drop { target_key: ID, role: String, @@ -161,7 +164,7 @@ public struct CapabilityAdminPermissions has copy, drop, store { /// To perform additional access control checks, integrating modules need to wrap the `RoleMap::is_capability_valid()` call /// in their own `is_capability_valid()` implementation, use this wrapper function for evaluating the additional checks /// and use the role-data to store role specific variables. `RoleMap::is_capability_valid()` itself will ignore the role-data. -public struct RoleMap has copy, drop, store { +public struct RoleMap has store { /// Identifies the scope (or domain) managed by the RoleMap. Usually this is the ID of the managed onchain object /// (i.e. an audit trail). You can also derive an arbitrary ID value reused by several managed onchain objects /// to share the used roles and capabilities between these objects. @@ -171,8 +174,11 @@ public struct RoleMap has copy, drop, store { /// Name of the initial admin role created by `new`. /// The RoleMap uses this to protect that role from unsafe changes. initial_admin_role_name: String, - /// Allowlist of all issued capability IDs - issued_capabilities: VecSet, + /// Denylist of all revoked capability IDs mapped to their optional valid_until timestamp (if any). + /// If a revoked capability has no valid_until timestamp, its u64 value is set to 0. + /// The optional valid_until timestamp allows for automatic removal of expired capabilities to keep the list as + /// short as possible (see function `revoke_capability()` for more details). + revoked_capabilities: LinkedTable, /// IDs of active capabilities for the initial admin role. /// These IDs cannot be removed through generic revoke/destroy functions. /// Use `revoke_initial_admin_capability` or `destroy_initial_admin_capability` instead. @@ -271,8 +277,6 @@ public fun new( option::none(), ctx, ); - let mut issued_capabilities = vec_set::empty(); - issued_capabilities.insert(admin_cap.id()); let mut initial_admin_cap_ids = vec_set::empty(); initial_admin_cap_ids.insert(admin_cap.id()); let role_map = RoleMap { @@ -281,13 +285,34 @@ public fun new( role_admin_permissions, capability_admin_permissions, target_key, - issued_capabilities, + revoked_capabilities: linked_table::new(ctx), initial_admin_cap_ids, }; (role_map, admin_cap) } +/// Safely destroys a RoleMap. +/// Will destroy all stored roles and capabilities. +public fun destroy(self: RoleMap) { + let RoleMap { + roles: _, + initial_admin_role_name: _, + role_admin_permissions: _, + capability_admin_permissions: _, + target_key: _, + mut revoked_capabilities, + initial_admin_cap_ids: _, + } = self; + + while (!revoked_capabilities.is_empty()) { + revoked_capabilities.pop_front(); + }; + revoked_capabilities.destroy_empty(); +} + +// ============ Role Functions ==================== + /// Get the permissions associated with a specific role. /// Aborts with `ERoleDoesNotExist` if the role does not exist. public fun get_role_permissions( @@ -441,16 +466,17 @@ public(package) fun new_role( } /// ===== Capability Functions ======= + /// Indicates if a provided capability is valid. /// /// A capability is considered valid if: /// - The capability's target_key matches the RoleMap's target_key. -/// Aborts with ECapabilitySecurityVaultIdMismatch if not matching. +/// Aborts with ECapabilityTargetKeyMismatch 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). +/// - The capability has not been revoked (is not included in the `revoked_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. @@ -459,7 +485,6 @@ public(package) fun new_role( /// /// Parameters /// ---------- -/// - self: 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. @@ -473,12 +498,12 @@ public fun assert_capability_valid( clock: &Clock, ctx: &TxContext, ) { - assert!(self.target_key == cap.target_key(), ECapabilitySecurityVaultIdMismatch); + assert!(self.target_key == cap.target_key(), ECapabilityTargetKeyMismatch); let permissions = self.get_role_permissions(cap.role()); assert!(vec_set::contains(permissions, permission), ECapabilityPermissionDenied); - assert!(self.issued_capabilities.contains(&cap.id()), ECapabilityHasBeenRevoked); + assert!(!self.revoked_capabilities.contains(cap.id()), ECapabilityHasBeenRevoked); if (cap.valid_from().is_some() || cap.valid_until().is_some()) { assert!(cap.is_currently_valid(clock), ECapabilityTimeConstraintsNotMet); @@ -495,8 +520,8 @@ public fun assert_capability_valid( /// /// Parameters /// ---------- -/// - self: Reference to the `RoleMap` mapping. /// - cap: Reference to the capability used to authorize the creation of the new capability. +/// Needs to grant the `CapabilityAdminPermissions::add` permission. /// - 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 milliseconds since Unix epoch) for the new capability. @@ -508,7 +533,6 @@ public fun assert_capability_valid( /// /// Errors: /// - Aborts with any error documented by `assert_capability_valid` if the provided capability fails authorization checks. -/// - The provided capability needs to grant the `CapabilityAdminPermissions::add` permission. /// - Aborts with `ERoleDoesNotExist` if the specified role does not exist in the role_map. /// - Aborts with `tf_components::capability::EValidityPeriodInconsistent` if the provided valid_from and valid_until are inconsistent. /// @@ -549,17 +573,19 @@ public fun new_capability( /// /// Initial admin capabilities cannot be destroyed via this function. /// Use `destroy_initial_admin_capability` instead. +/// +/// Will remove the capability from the `revoked_capabilities` denylist if it's included. /// /// Sends a `CapabilityDestroyed` event upon successful destruction. public fun destroy_capability(self: &mut RoleMap, cap_to_destroy: Capability) { - assert!(self.target_key == cap_to_destroy.target_key(), ECapabilitySecurityVaultIdMismatch); + assert!(self.target_key == cap_to_destroy.target_key(), ECapabilityTargetKeyMismatch); assert!( !self.initial_admin_cap_ids.contains(&cap_to_destroy.id()), EInitialAdminCapabilityMustBeExplicitlyDestroyed, ); - if (self.issued_capabilities.contains(&cap_to_destroy.id())) { - self.issued_capabilities.remove(&cap_to_destroy.id()); + if (self.revoked_capabilities.contains(cap_to_destroy.id())) { + self.revoked_capabilities.remove(cap_to_destroy.id()); }; event::emit(CapabilityDestroyed { @@ -576,20 +602,86 @@ public fun destroy_capability(self: &mut RoleMap /// Revoke an existing capability /// -/// Initial admin capabilities cannot be revoked via this function. -/// Use `revoke_initial_admin_capability` instead. -/// -/// Sends a `CapabilityRevoked` event upon successful revocation. -/// +/// Notes +/// ----- +/// * Initial admin capabilities cannot be revoked via this function. +/// Use `revoke_initial_admin_capability` instead. +/// +/// * Sends a `CapabilityRevoked` event upon successful revocation. +/// +/// Off-chain tracking for issued capabilities +/// ------------------------------------------ +/// The `RoleMap` has been designed for users issuing high numbers of capabilities. +/// +/// It therefore uses a denylist to manage revoked capabilities. Inherently, a denylist doesn't allow +/// to track all issued capabilities on-chain. Tracking issued capability ID's and their validity constraints +/// on-chain would lead to high storage costs and would slow down capability validity checks. +/// +/// The `revoke_capability()` function therefore relies on the user to provide correct information about +/// the capability to revoke, which is the main challenge of this approach: +/// +/// **Users of the `RoleMap` need to have an off-chain tracking mechanism for issued capabilities, +/// their id's and optional constraints.** +/// +/// The main strength of this approach is that it allows to keep the internally managed denylist +/// (`revoked_capabilities`) as short as possible, by only including capabilities that are actually +/// revoked and haven't expired yet. +/// +/// To keep the denylist as short as possible, we recommend to: +/// * Use the `Capability::valid_until` field to set an expiry date for issued capabilities whenever possible +/// * Track the id and expiry date of issued capabilities off-chain +/// * Set the `cap_to_revoke_valid_until` parameter (see below) to the `valid_until` value of the capability +/// when revoking a capability +/// * Frequently use the `cleanup_revoked_capabilities()` function to automatically remove expired capabilities from list +/// +/// Please note: Revoked capabilities without an expiry date need to be included in the list until they are explicitly +/// destroyed. This means in case users miss to destroy capabilities, these capabilities will remain in the list infinitely. +/// As there is no maximum size to be taken into account, this is not a problem per se but should be avoided. +/// +/// **Keeping the denylist as short as possible is crucial for users issuing high numbers of capabilities, +/// to avoid high storage costs and performance issues.** +/// +/// See the `cap_to_revoke` parameter documentation below for the constraints that need to be fulfilled for the +/// `revoke_capability()` function to work correctly. +/// +/// If users of the RoleMap only issue minor numbers of capabilities, they can also choose to set the +/// `cap_to_revoke_valid_until` parameter to `option::none()` when revoking a capability. In this case, the off-chain +/// tracking mechanism only needs to maintain a list of already issued capability ID's. +/// +/// Parameters +/// ---------- +/// - cap: Reference to the capability used to authorize the revocation of the `cap_to_revoke` capability. +/// Needs to grant the `CapabilityAdminPermissions::revoke` permission. +/// - cap_to_revoke: +/// The capability to be revoked is specified by its ID (see above for more details). +/// The user of this function is responsible to only pass `cap_to_revoke` values that meet the following preconditions: +/// * A capability with the provided `cap_to_revoke` ID exists +/// * The capability specified by `cap_to_revoke` has been issued by this RoleMap instance +/// * The optional `valid_until` value of the capability specified by `cap_to_revoke` has not expired +/// (in this case there would be no need to revoke it) +/// +/// To meet these preconditions, the off-chain tracking mechanism implemented by the user of the RoleMap is responsible for +/// registering all issued capabilities, their ID's and optional expiry dates. +/// +/// **The `revoke_capability()` function itself will not evaluate any of the above listed checks.** +/// +/// If you provide i.e. random `cap_to_revoke` ID's they will be stored in the `revoked_capabilities` without any errors. +/// - cap_to_revoke_valid_until: If specified, the `valid_until` value of the capability specified by `cap_to_revoke` (see +/// above for more details). +/// This value will be stored in the `revoked_capabilities` denylist and can be used later on to do automatic list cleanups +/// by removing already expired capabilities from the list. +/// - clock: Reference to a Clock instance for time-based validation. +/// - ctx: Reference to the transaction context. +/// /// Errors: /// - Aborts with any error documented by `assert_capability_valid` if the provided capability fails authorization checks. -/// - The provided capability needs to grant the `CapabilityAdminPermissions::revoke` permission. -/// - Aborts with `ECapabilityNotIssued` if `cap_to_revoke` is not currently issued by this `RoleMap`. +/// - Aborts with `ECapabilityToRevokeHasAlreadyBeenRevoked` if `cap_to_revoke` has already been revoked. /// - Aborts with `EInitialAdminCapabilityMustBeExplicitlyDestroyed` if `cap_to_revoke` is an initial admin capability. public fun revoke_capability( self: &mut RoleMap, cap: &Capability, cap_to_revoke: ID, + cap_to_revoke_valid_until: Option, clock: &Clock, ctx: &TxContext, ) { @@ -600,17 +692,62 @@ public fun revoke_capability( ctx, ); - assert!(self.issued_capabilities.contains(&cap_to_revoke), ECapabilityNotIssued); assert!( !self.initial_admin_cap_ids.contains(&cap_to_revoke), EInitialAdminCapabilityMustBeExplicitlyDestroyed, ); - self.issued_capabilities.remove(&cap_to_revoke); - event::emit(CapabilityRevoked { - target_key: self.target_key, - capability_id: cap_to_revoke, - }); + self.add_cap_to_revoke_list(cap_to_revoke, cap_to_revoke_valid_until) +} + +/// Remove expired entries from the internally managed denylist (`revoked_capabilities`). +/// +/// Iterates through the denylist 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. +/// +/// See function `revoke_capability` above for more details. +/// +/// 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 RoleMap, + cap: &Capability, + clock: &Clock, + ctx: &TxContext, +) { + self.assert_capability_valid( + cap, + &self.capability_admin_permissions.revoke, + clock, + ctx, + ); + + let now = clock::timestamp_ms(clock); + let mut current_key = *self.revoked_capabilities.front(); + + while (current_key.is_some()) { + let key = *current_key.borrow(); + let valid_until = *self.revoked_capabilities.borrow(key); + // Peek at the next key before potentially removing the current node. + let next_key = *self.revoked_capabilities.next(key); + + if (valid_until > 0 && valid_until < now) { + self.revoked_capabilities.remove(key); + }; + + current_key = next_key; + }; } /// Destroy an initial admin capability. @@ -623,21 +760,25 @@ public fun revoke_capability( /// sealed with no admin access possible. /// /// Sends a `CapabilityDestroyed` event upon successful destruction. +/// +/// Will remove the capability from the `revoked_capabilities` denylist if it's included. /// /// Errors: -/// - Aborts with `ECapabilitySecurityVaultIdMismatch` if the capability's target_key does not match. +/// - Aborts with `ECapabilityTargetKeyMismatch` if the capability's target_key does not match. /// - Aborts with `ECapabilityIsNotInitialAdmin` if the capability is not an initial admin capability. public fun destroy_initial_admin_capability( self: &mut RoleMap, cap_to_destroy: Capability, ) { - assert!(self.target_key == cap_to_destroy.target_key(), ECapabilitySecurityVaultIdMismatch); + assert!(self.target_key == cap_to_destroy.target_key(), ECapabilityTargetKeyMismatch); assert!( self.initial_admin_cap_ids.contains(&cap_to_destroy.id()), ECapabilityIsNotInitialAdmin, ); - self.issued_capabilities.remove(&cap_to_destroy.id()); + if (self.revoked_capabilities.contains(cap_to_destroy.id())) { + self.revoked_capabilities.remove(cap_to_destroy.id()); + }; self.initial_admin_cap_ids.remove(&cap_to_destroy.id()); event::emit(CapabilityDestroyed { @@ -661,15 +802,19 @@ public fun destroy_initial_admin_capability( /// sealed with no admin access possible. /// /// Sends a `CapabilityRevoked` event upon successful revocation. +/// +/// See function `revoke_capability()` for parameter documentation and further details. /// /// Errors: /// - Aborts with any error documented by `assert_capability_valid` if the provided capability fails authorization checks. -/// - Aborts with `ECapabilityNotIssued` if `cap_to_revoke` is not currently issued by this `RoleMap`. +/// - The provided capability needs to grant the `CapabilityAdminPermissions::revoke` permission. +/// - Aborts with `ECapabilityToRevokeHasAlreadyBeenRevoked` if `cap_to_revoke` has already been revoked. /// - Aborts with `ECapabilityIsNotInitialAdmin` if `cap_to_revoke` is not an initial admin capability. public fun revoke_initial_admin_capability( self: &mut RoleMap, cap: &Capability, cap_to_revoke: ID, + cap_to_revoke_valid_until: Option, clock: &Clock, ctx: &TxContext, ) { @@ -680,16 +825,10 @@ public fun revoke_initial_admin_capability( ctx, ); - assert!(self.issued_capabilities.contains(&cap_to_revoke), ECapabilityNotIssued); assert!(self.initial_admin_cap_ids.contains(&cap_to_revoke), ECapabilityIsNotInitialAdmin); - self.issued_capabilities.remove(&cap_to_revoke); self.initial_admin_cap_ids.remove(&cap_to_revoke); - - event::emit(CapabilityRevoked { - target_key: self.target_key, - capability_id: cap_to_revoke, - }); + self.add_cap_to_revoke_list(cap_to_revoke, cap_to_revoke_valid_until) } /// Checks if the provided permissions include all required admin permissions @@ -709,7 +848,6 @@ fun has_required_admin_permissions( /// Issues a new capability fun issue_capability(self: &mut RoleMap, new_cap: &Capability) { - self.issued_capabilities.insert(new_cap.id()); if (new_cap.role() == &self.initial_admin_role_name) { self.initial_admin_cap_ids.insert(new_cap.id()); }; @@ -724,6 +862,24 @@ fun issue_capability(self: &mut RoleMap, n }); } +/// Add a capability to the revoke list +fun add_cap_to_revoke_list( + self: &mut RoleMap, + cap_to_revoke: ID, + cap_to_revoke_valid_until: Option +) { + assert!(!self.revoked_capabilities.contains(cap_to_revoke), ECapabilityToRevokeHasAlreadyBeenRevoked); + + let valid_until = cap_to_revoke_valid_until.borrow_with_default(&0); + self.revoked_capabilities.push_back(cap_to_revoke, *valid_until); + + event::emit(CapabilityRevoked { + target_key: self.target_key, + capability_id: cap_to_revoke, + valid_until: *valid_until, + }); +} + // =============== Getter Functions ====================== /// Returns the size of the role_map, the number of managed roles @@ -743,6 +899,6 @@ public fun role_admin_permissions( &self.role_admin_permissions } -public fun issued_capabilities(self: &RoleMap): &VecSet { - &self.issued_capabilities +public fun revoked_capabilities(self: &RoleMap): &LinkedTable { + &self.revoked_capabilities } diff --git a/components_move/tests/capability_component_tests.move b/components_move/tests/capability_component_tests.move index e43a5a1..126502d 100644 --- a/components_move/tests/capability_component_tests.move +++ b/components_move/tests/capability_component_tests.move @@ -15,7 +15,7 @@ fun test_capability_created_with_correct_field_values() { let admin_user = @0xAD; let mut scenario = ts::begin(admin_user); - let (_role_map, admin_cap, target_key) = test_utils::create_test_role_map( + let (role_map, admin_cap, target_key) = test_utils::create_test_role_map( ts::ctx(&mut scenario), ); @@ -31,6 +31,7 @@ fun test_capability_created_with_correct_field_values() { // Cleanup transfer::public_transfer(admin_cap, admin_user); + role_map.destroy(); ts::end(scenario); } @@ -39,7 +40,7 @@ fun test_has_role_returns_correct_values() { let admin_user = @0xAD; let mut scenario = ts::begin(admin_user); - let (_role_map, admin_cap, _security_vault_id) = test_utils::create_test_role_map( + let (role_map, admin_cap, _security_vault_id) = test_utils::create_test_role_map( ts::ctx(&mut scenario), ); @@ -50,6 +51,7 @@ fun test_has_role_returns_correct_values() { // Cleanup transfer::public_transfer(admin_cap, admin_user); + role_map.destroy(); ts::end(scenario); } @@ -87,6 +89,7 @@ fun test_capability_issued_to_specific_address() { iota::clock::destroy_for_testing(clock); transfer::public_transfer(admin_cap, admin_user); role_map.destroy_initial_admin_capability(user_cap); + role_map.destroy(); ts::end(scenario); } @@ -128,6 +131,7 @@ fun test_capability_valid_from_and_valid_until() { iota::clock::destroy_for_testing(clock); transfer::public_transfer(admin_cap, admin_user); role_map.destroy_initial_admin_capability(timed_cap); + role_map.destroy(); ts::end(scenario); } @@ -138,7 +142,7 @@ fun test_is_valid_for_timestamp_no_restrictions() { let admin_user = @0xAD; let mut scenario = ts::begin(admin_user); - let (_role_map, admin_cap, _security_vault_id) = test_utils::create_test_role_map( + let (role_map, admin_cap, _security_vault_id) = test_utils::create_test_role_map( ts::ctx(&mut scenario), ); @@ -149,6 +153,7 @@ fun test_is_valid_for_timestamp_no_restrictions() { // Cleanup transfer::public_transfer(admin_cap, admin_user); + role_map.destroy(); ts::end(scenario); } @@ -187,6 +192,7 @@ fun test_is_valid_for_timestamp_with_valid_from() { iota::clock::destroy_for_testing(clock); transfer::public_transfer(admin_cap, admin_user); role_map.destroy_initial_admin_capability(timed_cap); + role_map.destroy(); ts::end(scenario); } @@ -226,6 +232,7 @@ fun test_is_valid_for_timestamp_with_valid_until() { iota::clock::destroy_for_testing(clock); transfer::public_transfer(admin_cap, admin_user); role_map.destroy_initial_admin_capability(timed_cap); + role_map.destroy(); ts::end(scenario); } @@ -270,6 +277,7 @@ fun test_is_valid_for_timestamp_with_both_restrictions() { iota::clock::destroy_for_testing(clock); transfer::public_transfer(admin_cap, admin_user); role_map.destroy_initial_admin_capability(timed_cap); + role_map.destroy(); ts::end(scenario); } @@ -280,7 +288,7 @@ fun test_is_currently_valid_no_restrictions() { let admin_user = @0xAD; let mut scenario = ts::begin(admin_user); - let (_role_map, admin_cap, _security_vault_id) = test_utils::create_test_role_map( + let (role_map, admin_cap, _security_vault_id) = test_utils::create_test_role_map( ts::ctx(&mut scenario), ); @@ -293,6 +301,7 @@ fun test_is_currently_valid_no_restrictions() { // Cleanup iota::clock::destroy_for_testing(clock); transfer::public_transfer(admin_cap, admin_user); + role_map.destroy(); ts::end(scenario); } @@ -331,6 +340,7 @@ fun test_is_currently_valid_within_validity_period() { iota::clock::destroy_for_testing(clock); transfer::public_transfer(admin_cap, admin_user); role_map.destroy_initial_admin_capability(timed_cap); + role_map.destroy(); ts::end(scenario); } @@ -369,6 +379,7 @@ fun test_is_currently_valid_before_validity_period() { iota::clock::destroy_for_testing(clock); transfer::public_transfer(admin_cap, admin_user); role_map.destroy_initial_admin_capability(timed_cap); + role_map.destroy(); ts::end(scenario); } @@ -407,6 +418,7 @@ fun test_is_currently_valid_after_validity_period() { iota::clock::destroy_for_testing(clock); transfer::public_transfer(admin_cap, admin_user); role_map.destroy_initial_admin_capability(timed_cap); + role_map.destroy(); ts::end(scenario); } @@ -453,5 +465,6 @@ fun test_capability_with_all_restrictions() { iota::clock::destroy_for_testing(clock); transfer::public_transfer(admin_cap, admin_user); role_map.destroy_initial_admin_capability(restricted_cap); + role_map.destroy(); ts::end(scenario); } diff --git a/components_move/tests/role_map_tests.move b/components_move/tests/role_map_tests.move index 5fdface..fb80462 100644 --- a/components_move/tests/role_map_tests.move +++ b/components_move/tests/role_map_tests.move @@ -9,6 +9,7 @@ use std::string::String; use iota::{test_scenario as ts, vec_set}; use std::string; use tf_components::{core_test_utils as test_utils, role_map}; +use tf_components::capability::Capability; #[test] fun test_role_based_permission_delegation() { @@ -124,6 +125,7 @@ fun test_role_based_permission_delegation() { transfer::public_transfer(admin_cap, admin_user); // Cleanup + role_map.destroy(); ts::next_tx(&mut scenario, admin_user); ts::end(scenario); } @@ -156,6 +158,7 @@ fun test_new_fails_with_empty_initial_admin_permissions() { role_map.destroy_initial_admin_capability(admin_cap); + role_map.destroy(); ts::end(scenario); } @@ -180,6 +183,7 @@ fun test_delete_initial_admin_role_fails() { iota::clock::destroy_for_testing(clock); role_map.destroy_initial_admin_capability(admin_cap); + role_map.destroy(); ts::end(scenario); } @@ -207,6 +211,7 @@ fun test_update_initial_admin_role_removing_required_permissions_fails() { iota::clock::destroy_for_testing(clock); role_map.destroy_initial_admin_capability(admin_cap); + role_map.destroy(); ts::end(scenario); } @@ -226,12 +231,14 @@ fun test_revoke_initial_admin_capability_blocked_on_normal_revoke() { role_map.revoke_capability( &admin_cap, admin_cap.id(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); iota::clock::destroy_for_testing(clock); role_map.destroy_initial_admin_capability(admin_cap); + role_map.destroy(); ts::end(scenario); } @@ -246,6 +253,7 @@ fun test_destroy_initial_admin_capability_blocked_on_normal_destroy() { ); role_map.destroy_capability(admin_cap); + role_map.destroy(); ts::end(scenario); } @@ -277,6 +285,7 @@ fun test_revoke_second_initial_admin_capability_blocked_on_normal_revoke() { role_map.revoke_capability( &admin_cap, second_admin_cap.id(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -284,6 +293,7 @@ fun test_revoke_second_initial_admin_capability_blocked_on_normal_revoke() { iota::clock::destroy_for_testing(clock); role_map.destroy_initial_admin_capability(admin_cap); role_map.destroy_initial_admin_capability(second_admin_cap); + role_map.destroy(); ts::end(scenario); } @@ -315,6 +325,7 @@ fun test_destroy_second_initial_admin_capability_blocked_on_normal_destroy() { iota::clock::destroy_for_testing(clock); transfer::public_transfer(admin_cap, admin_user); role_map.destroy_capability(second_admin_cap); + role_map.destroy(); ts::end(scenario); } @@ -340,16 +351,14 @@ fun test_destroy_initial_admin_capability_works() { &clock, ts::ctx(&mut scenario), ); - let second_admin_cap_id = second_admin_cap.id(); + let _second_admin_cap_id = second_admin_cap.id(); // Destroy the first admin cap via explicit API role_map.destroy_initial_admin_capability(admin_cap); - assert!(role_map.issued_capabilities().size() == 1, 0); - assert!(role_map.issued_capabilities().contains(&second_admin_cap_id), 1); - iota::clock::destroy_for_testing(clock); transfer::public_transfer(second_admin_cap, admin_user); + role_map.destroy(); ts::end(scenario); } @@ -380,18 +389,23 @@ fun test_revoke_initial_admin_capability_works() { role_map.revoke_initial_admin_capability( &second_admin_cap, admin_cap.id(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); - assert!(role_map.issued_capabilities().size() == 1, 0); - assert!(role_map.issued_capabilities().contains(&second_admin_cap.id()), 1); + assert!(role_map.revoked_capabilities().length() == 1, 0); + assert!(role_map.revoked_capabilities().contains(admin_cap.id()), 1); // The revoked cap object can still be destroyed via normal destroy (no longer in initial_admin_cap_ids) role_map.destroy_capability(admin_cap); + // After being destroyed, the admin_cap is not contained in the revoked_capabilities list anymore + assert!(role_map.revoked_capabilities().length() == 0, 2); + iota::clock::destroy_for_testing(clock); transfer::public_transfer(second_admin_cap, admin_user); + role_map.destroy(); ts::end(scenario); } @@ -407,8 +421,7 @@ fun test_destroy_last_initial_admin_capability_seals_role_map() { // Destroy the only admin cap — seals the RoleMap role_map.destroy_initial_admin_capability(admin_cap); - assert!(role_map.issued_capabilities().size() == 0, 0); - + role_map.destroy(); ts::end(scenario); } @@ -423,19 +436,25 @@ fun test_revoke_last_initial_admin_capability_seals_role_map() { let clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); + // Initially the `revoked_capabilities` list must be empty + assert!(role_map.revoked_capabilities().length() == 0, 0); + // Revoke the only admin cap — seals the RoleMap role_map.revoke_initial_admin_capability( &admin_cap, admin_cap.id(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); - assert!(role_map.issued_capabilities().size() == 0, 0); + assert!(role_map.revoked_capabilities().length() == 1, 1); + assert!(role_map.revoked_capabilities().contains(admin_cap.id()), 2); iota::clock::destroy_for_testing(clock); // The revoked cap can still be destroyed for cleanup role_map.destroy_capability(admin_cap); + role_map.destroy(); ts::end(scenario); } @@ -459,21 +478,26 @@ fun test_initial_admin_capability_rotation_works() { &clock, ts::ctx(&mut scenario), ); - let rotated_admin_cap_id = rotated_admin_cap.id(); + let _rotated_admin_cap_id = rotated_admin_cap.id(); + + // Initially the `revoked_capabilities` list must be empty + assert!(role_map.revoked_capabilities().length() == 0, 0); // Use the explicit API to revoke the old admin cap role_map.revoke_initial_admin_capability( &admin_cap, admin_cap.id(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); - assert!(role_map.issued_capabilities().size() == 1, 0); - assert!(role_map.issued_capabilities().contains(&rotated_admin_cap_id), 1); + assert!(role_map.revoked_capabilities().length() == 1, 1); + assert!(role_map.revoked_capabilities().contains(admin_cap.id()), 2); iota::clock::destroy_for_testing(clock); role_map.destroy_capability(admin_cap); + role_map.destroy(); transfer::public_transfer(rotated_admin_cap, admin_user); ts::end(scenario); } @@ -513,6 +537,7 @@ fun test_destroy_initial_admin_capability_rejects_non_admin_cap() { iota::clock::destroy_for_testing(clock); transfer::public_transfer(admin_cap, admin_user); role_map.destroy_initial_admin_capability(reader_cap); + role_map.destroy(); ts::end(scenario); } @@ -551,6 +576,7 @@ fun test_revoke_initial_admin_capability_rejects_non_admin_cap() { role_map.revoke_initial_admin_capability( &admin_cap, reader_cap.id(), + std::option::none(), &clock, ts::ctx(&mut scenario), ); @@ -558,5 +584,838 @@ fun test_revoke_initial_admin_capability_rejects_non_admin_cap() { iota::clock::destroy_for_testing(clock); transfer::public_transfer(admin_cap, admin_user); role_map.destroy_capability(reader_cap); + role_map.destroy(); + ts::end(scenario); +} + +// Test proper capability revocation +#[test] +fun test_proper_capability_revocation() { + let admin_user = @0xAD; + let mut scenario = ts::begin(admin_user); + + // Step 1: admin_user creates the role map + let (mut role_map, admin_cap, _target_key) = test_utils::create_test_role_map( + ts::ctx(&mut scenario), + ); + + let clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); + + // Step 2: admin_user creates CapAdmin role + role_map.create_role( + &admin_cap, + string::utf8(b"CapAdmin"), + vec_set::singleton(test_utils::manage_capabilities()), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + let cap_admin_role = string::utf8(b"CapAdmin"); + + // Step 3: admin_user creates cap_admin_1, cap_admin_2, cap_admin_3 with CapAdmin role + let cap_admin_1 = role_map.new_capability( + &admin_cap, + &cap_admin_role, + std::option::none(), + std::option::none(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + let cap_admin_1_id = cap_admin_1.id(); + + let cap_admin_2 = role_map.new_capability( + &admin_cap, + &cap_admin_role, + std::option::none(), + std::option::none(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + let cap_admin_2_id = cap_admin_2.id(); + + let cap_admin_3 = role_map.new_capability( + &admin_cap, + &cap_admin_role, + std::option::none(), + std::option::none(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + let cap_admin_3_id = cap_admin_3.id(); + + // Step 4: admin_user revokes cap_admin_2 + role_map.revoke_capability( + &admin_cap, + cap_admin_2_id, + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + // Step 5: Verify cap_admin_2 is revoked and the others are not + assert!(role_map.revoked_capabilities().contains(cap_admin_2_id), 0); + assert!(!role_map.revoked_capabilities().contains(cap_admin_1_id), 1); + assert!(!role_map.revoked_capabilities().contains(cap_admin_3_id), 2); + assert!(role_map.revoked_capabilities().length() == 1, 3); + + // Step 6: admin_user creates cap_admin_4, cap_admin_5 + let cap_admin_4 = role_map.new_capability( + &admin_cap, + &cap_admin_role, + std::option::none(), + std::option::none(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + let _cap_admin_4_id = cap_admin_4.id(); + + let cap_admin_5 = role_map.new_capability( + &admin_cap, + &cap_admin_role, + std::option::none(), + std::option::none(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + let cap_admin_5_id = cap_admin_5.id(); + + // Step 7: admin_user revokes cap_admin_1 and cap_admin_5 + role_map.revoke_capability( + &admin_cap, + cap_admin_1_id, + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + role_map.revoke_capability( + &admin_cap, + cap_admin_5_id, + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + // Step 8: Verify cap_admin_1 and cap_admin_5 are revoked and the others are not + assert!(role_map.revoked_capabilities().contains(cap_admin_1_id), 4); + assert!(role_map.revoked_capabilities().contains(cap_admin_2_id), 5); + assert!(role_map.revoked_capabilities().contains(cap_admin_5_id), 6); + assert!(!role_map.revoked_capabilities().contains(cap_admin_3_id), 7); + assert!(!role_map.revoked_capabilities().contains(_cap_admin_4_id), 8); + assert!(role_map.revoked_capabilities().length() == 3, 9); + + // Step 9: Verify that `cleanup_revoked_capabilities` doesn't remove revoked capabilities that are still active + role_map.cleanup_revoked_capabilities( + &admin_cap, + &clock, + ts::ctx(&mut scenario) + ); + assert!(role_map.revoked_capabilities().contains(cap_admin_1_id), 10); + assert!(role_map.revoked_capabilities().contains(cap_admin_2_id), 11); + assert!(role_map.revoked_capabilities().contains(cap_admin_5_id), 12); + assert!(role_map.revoked_capabilities().length() == 3, 13); + + // Cleanup + iota::clock::destroy_for_testing(clock); + role_map.destroy_capability(cap_admin_1); + role_map.destroy_capability(cap_admin_2); + role_map.destroy_capability(cap_admin_3); + role_map.destroy_capability(cap_admin_4); + role_map.destroy_capability(cap_admin_5); + role_map.destroy_initial_admin_capability(admin_cap); + role_map.destroy(); + ts::end(scenario); +} + +// Test `cleanup_revoked_capabilities` removes revoked capabilities that are no longer active +#[test] +fun test_cleanup_revoked_capabilities_list_removes_expired() { + let admin_user = @0xAD; + let mut scenario = ts::begin(admin_user); + + let (mut role_map, admin_cap, _target_key) = test_utils::create_test_role_map( + ts::ctx(&mut scenario), + ); + + let mut clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); + + // Create a CapAdmin role + role_map.create_role( + &admin_cap, + string::utf8(b"CapAdmin"), + vec_set::singleton(test_utils::manage_capabilities()), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + let cap_admin_role = string::utf8(b"CapAdmin"); + + // Create cap_1 with valid_until = 100 (will expire) + let cap_1 = role_map.new_capability( + &admin_cap, + &cap_admin_role, + std::option::none(), + std::option::none(), + std::option::some(100), + &clock, + ts::ctx(&mut scenario), + ); + let cap_1_id = cap_1.id(); + + // Create cap_2 with valid_until = 200 (will expire) + let cap_2 = role_map.new_capability( + &admin_cap, + &cap_admin_role, + std::option::none(), + std::option::none(), + std::option::some(200), + &clock, + ts::ctx(&mut scenario), + ); + let cap_2_id = cap_2.id(); + + // Create cap_3 with no valid_until (never expires) + let cap_3 = role_map.new_capability( + &admin_cap, + &cap_admin_role, + std::option::none(), + std::option::none(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + let cap_3_id = cap_3.id(); + + // Create cap_4 with valid_until = 500 (will not expire yet) + let cap_4 = role_map.new_capability( + &admin_cap, + &cap_admin_role, + std::option::none(), + std::option::none(), + std::option::some(500), + &clock, + ts::ctx(&mut scenario), + ); + let cap_4_id = cap_4.id(); + + // Revoke all four capabilities with their respective valid_until values + role_map.revoke_capability( + &admin_cap, + cap_1_id, + std::option::some(100), + &clock, + ts::ctx(&mut scenario), + ); + role_map.revoke_capability( + &admin_cap, + cap_2_id, + std::option::some(200), + &clock, + ts::ctx(&mut scenario), + ); + role_map.revoke_capability( + &admin_cap, + cap_3_id, + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + role_map.revoke_capability( + &admin_cap, + cap_4_id, + std::option::some(500), + &clock, + ts::ctx(&mut scenario), + ); + + // All 4 should be in the revoked list + assert!(role_map.revoked_capabilities().length() == 4, 0); + + // Advance clock to 300 — cap_1 (valid_until=100) and cap_2 (valid_until=200) are now expired + iota::clock::set_for_testing(&mut clock, 300); + + // Cleanup should remove expired entries + role_map.cleanup_revoked_capabilities( + &admin_cap, + &clock, + ts::ctx(&mut scenario), + ); + + // cap_1 and cap_2 should be removed (expired), cap_3 and cap_4 should remain + assert!(role_map.revoked_capabilities().length() == 2, 1); + assert!(!role_map.revoked_capabilities().contains(cap_1_id), 2); + assert!(!role_map.revoked_capabilities().contains(cap_2_id), 3); + assert!(role_map.revoked_capabilities().contains(cap_3_id), 4); + assert!(role_map.revoked_capabilities().contains(cap_4_id), 5); + + // Advance clock to 600 — cap_4 (valid_until=500) is now also expired + iota::clock::set_for_testing(&mut clock, 600); + + role_map.cleanup_revoked_capabilities( + &admin_cap, + &clock, + ts::ctx(&mut scenario), + ); + + // Only cap_3 (no expiry) should remain + assert!(role_map.revoked_capabilities().length() == 1, 6); + assert!(role_map.revoked_capabilities().contains(cap_3_id), 7); + assert!(!role_map.revoked_capabilities().contains(cap_4_id), 8); + + // Cleanup + iota::clock::destroy_for_testing(clock); + role_map.destroy_capability(cap_1); + role_map.destroy_capability(cap_2); + role_map.destroy_capability(cap_3); + role_map.destroy_capability(cap_4); + role_map.destroy_initial_admin_capability(admin_cap); + role_map.destroy(); + ts::end(scenario); +} + +// ===== Tests: assert_capability_valid error paths ===== + +#[test] +#[expected_failure(abort_code = role_map::ECapabilityTargetKeyMismatch)] +fun test_assert_capability_valid_fails_on_target_key_mismatch() { + let admin_user = @0xAD; + let mut scenario = ts::begin(admin_user); + + // Create two RoleMaps with different target keys + let (mut role_map_a, admin_cap_a, _target_key_a) = test_utils::create_test_role_map( + ts::ctx(&mut scenario), + ); + + let target_key_b = test_utils::fake_object_id_from_string( + &b"DifferentVault".to_string(), + ); + let (role_admin_permissions, capability_admin_permissions) = test_utils::get_admin_permissions(); + let (mut role_map_b, admin_cap_b) = role_map::new<_, bool>( + target_key_b, + test_utils::initial_admin_role_name(), + test_utils::super_admin_permissions(), + role_admin_permissions, + capability_admin_permissions, + ts::ctx(&mut scenario), + ); + + let clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); + + // Use cap from role_map_b to try to create a role on role_map_a — target_key mismatch + role_map_a.create_role( + &admin_cap_b, + string::utf8(b"Reader"), + vec_set::singleton(test_utils::action_a()), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + iota::clock::destroy_for_testing(clock); + role_map_a.destroy_initial_admin_capability(admin_cap_a); + role_map_b.destroy_initial_admin_capability(admin_cap_b); + role_map_a.destroy(); + role_map_b.destroy(); + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = role_map::ECapabilityPermissionDenied)] +fun test_assert_capability_valid_fails_on_permission_denied() { + let admin_user = @0xAD; + let mut scenario = ts::begin(admin_user); + + let (mut role_map, admin_cap, _target_key) = test_utils::create_test_role_map( + ts::ctx(&mut scenario), + ); + + let clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); + + // Create a role with only ActionA permission + role_map.create_role( + &admin_cap, + string::utf8(b"LimitedRole"), + vec_set::singleton(test_utils::action_a()), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + // Issue a capability for the LimitedRole + let limited_cap = role_map.new_capability( + &admin_cap, + &string::utf8(b"LimitedRole"), + std::option::none(), + std::option::none(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + // Try to create a role using the limited cap — it lacks ManageRoles permission + role_map.create_role( + &limited_cap, + string::utf8(b"AnotherRole"), + vec_set::singleton(test_utils::action_b()), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + iota::clock::destroy_for_testing(clock); + role_map.destroy_capability(limited_cap); + role_map.destroy_initial_admin_capability(admin_cap); + role_map.destroy(); ts::end(scenario); } + +#[test] +#[expected_failure(abort_code = role_map::ECapabilityHasBeenRevoked)] +fun test_assert_capability_valid_fails_on_revoked_capability() { + let admin_user = @0xAD; + let mut scenario = ts::begin(admin_user); + + let (mut role_map, admin_cap, _target_key) = test_utils::create_test_role_map( + ts::ctx(&mut scenario), + ); + + let clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); + let initial_role = test_utils::initial_admin_role_name(); + + // Issue a second admin cap, then revoke it + let second_admin_cap = role_map.new_capability( + &admin_cap, + &initial_role, + std::option::none(), + std::option::none(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + role_map.revoke_initial_admin_capability( + &admin_cap, + second_admin_cap.id(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + // Try to use the revoked capability to create a role + role_map.create_role( + &second_admin_cap, + string::utf8(b"NewRole"), + vec_set::singleton(test_utils::action_a()), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + iota::clock::destroy_for_testing(clock); + role_map.destroy_capability(second_admin_cap); + role_map.destroy_initial_admin_capability(admin_cap); + role_map.destroy(); + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = role_map::ECapabilityTimeConstraintsNotMet)] +fun test_assert_capability_valid_fails_on_not_yet_valid_capability() { + let admin_user = @0xAD; + let mut scenario = ts::begin(admin_user); + + let (mut role_map, admin_cap, _target_key) = test_utils::create_test_role_map( + ts::ctx(&mut scenario), + ); + + let clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); + // clock is at time 0 + + let initial_role = test_utils::initial_admin_role_name(); + + // Issue a cap that is valid_from = 1000 (not yet valid) + let future_cap = role_map.new_capability( + &admin_cap, + &initial_role, + std::option::none(), + std::option::some(1000), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + // Try to use the not-yet-valid capability + role_map.create_role( + &future_cap, + string::utf8(b"NewRole"), + vec_set::singleton(test_utils::action_a()), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + iota::clock::destroy_for_testing(clock); + role_map.destroy_initial_admin_capability(future_cap); + role_map.destroy_initial_admin_capability(admin_cap); + role_map.destroy(); + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = role_map::ECapabilityTimeConstraintsNotMet)] +fun test_assert_capability_valid_fails_on_expired_capability() { + let admin_user = @0xAD; + let mut scenario = ts::begin(admin_user); + + let (mut role_map, admin_cap, _target_key) = test_utils::create_test_role_map( + ts::ctx(&mut scenario), + ); + + let mut clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); + let initial_role = test_utils::initial_admin_role_name(); + + // Issue a cap that valid_until = 100 + let expiring_cap = role_map.new_capability( + &admin_cap, + &initial_role, + std::option::none(), + std::option::none(), + std::option::some(100), + &clock, + ts::ctx(&mut scenario), + ); + + // Advance clock past expiry + iota::clock::set_for_testing(&mut clock, 200); + + // Try to use the expired capability + role_map.create_role( + &expiring_cap, + string::utf8(b"NewRole"), + vec_set::singleton(test_utils::action_a()), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + iota::clock::destroy_for_testing(clock); + role_map.destroy_initial_admin_capability(expiring_cap); + role_map.destroy_initial_admin_capability(admin_cap); + role_map.destroy(); + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = role_map::ECapabilityIssuedToMismatch)] +fun test_assert_capability_valid_fails_on_issued_to_mismatch() { + let admin_user = @0xAD; + let other_user = @0xBE; + let mut scenario = ts::begin(admin_user); + + let (mut role_map, admin_cap, _target_key) = test_utils::create_test_role_map( + ts::ctx(&mut scenario), + ); + + let clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); + let initial_role = test_utils::initial_admin_role_name(); + + // Issue a cap restricted to other_user + let restricted_cap = role_map.new_capability( + &admin_cap, + &initial_role, + std::option::some(other_user), + std::option::none(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + // admin_user (sender) tries to use the cap restricted to other_user + role_map.create_role( + &restricted_cap, + string::utf8(b"NewRole"), + vec_set::singleton(test_utils::action_a()), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + iota::clock::destroy_for_testing(clock); + role_map.destroy_initial_admin_capability(restricted_cap); + role_map.destroy_initial_admin_capability(admin_cap); + role_map.destroy(); + ts::end(scenario); +} + +// ===== Tests: delete_role happy path and error paths ===== + +#[test] +fun test_delete_role_succeeds() { + let admin_user = @0xAD; + let mut scenario = ts::begin(admin_user); + + let (mut role_map, admin_cap, _target_key) = test_utils::create_test_role_map( + ts::ctx(&mut scenario), + ); + + let clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); + + // Create a role + role_map.create_role( + &admin_cap, + string::utf8(b"Reader"), + vec_set::singleton(test_utils::action_a()), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + assert!(role_map.size() == 2, 0); + assert!(role_map.has_role(&b"Reader".to_string()), 1); + + // Delete the role + role_map.delete_role( + &admin_cap, + &string::utf8(b"Reader"), + &clock, + ts::ctx(&mut scenario), + ); + assert!(role_map.size() == 1, 2); + assert!(!role_map.has_role(&b"Reader".to_string()), 3); + + iota::clock::destroy_for_testing(clock); + role_map.destroy_initial_admin_capability(admin_cap); + role_map.destroy(); + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = role_map::ERoleDoesNotExist)] +fun test_delete_role_fails_on_nonexistent_role() { + let admin_user = @0xAD; + let mut scenario = ts::begin(admin_user); + + let (mut role_map, admin_cap, _target_key) = test_utils::create_test_role_map( + ts::ctx(&mut scenario), + ); + + let clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); + + // Try to delete a role that doesn't exist + role_map.delete_role( + &admin_cap, + &string::utf8(b"NonExistent"), + &clock, + ts::ctx(&mut scenario), + ); + + iota::clock::destroy_for_testing(clock); + role_map.destroy_initial_admin_capability(admin_cap); + role_map.destroy(); + ts::end(scenario); +} + +// ===== Tests: get_role_permissions / get_role_data error paths ===== + +#[test] +#[expected_failure(abort_code = role_map::ERoleDoesNotExist)] +fun test_get_role_permissions_fails_on_nonexistent_role() { + let admin_user = @0xAD; + let mut scenario = ts::begin(admin_user); + + let (mut role_map, admin_cap, _target_key) = test_utils::create_test_role_map( + ts::ctx(&mut scenario), + ); + + let _perms = role_map.get_role_permissions<_, bool>(&b"NonExistent".to_string()); + + role_map.destroy_initial_admin_capability(admin_cap); + role_map.destroy(); + ts::end(scenario); +} + +#[test] +#[expected_failure(abort_code = role_map::ERoleDoesNotExist)] +fun test_get_role_data_fails_on_nonexistent_role() { + let admin_user = @0xAD; + let mut scenario = ts::begin(admin_user); + + let (mut role_map, admin_cap, _target_key) = test_utils::create_test_role_map( + ts::ctx(&mut scenario), + ); + + let _data = role_map.get_role_data<_, bool>(&b"NonExistent".to_string()); + + role_map.destroy_initial_admin_capability(admin_cap); + role_map.destroy(); + ts::end(scenario); +} + +// ===== Tests: update_role error path for nonexistent role ===== + +#[test] +#[expected_failure(abort_code = role_map::ERoleDoesNotExist)] +fun test_update_role_fails_on_nonexistent_role() { + let admin_user = @0xAD; + let mut scenario = ts::begin(admin_user); + + let (mut role_map, admin_cap, _target_key) = test_utils::create_test_role_map( + ts::ctx(&mut scenario), + ); + + let clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); + + role_map.update_role( + &admin_cap, + &string::utf8(b"NonExistent"), + vec_set::singleton(test_utils::action_a()), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + iota::clock::destroy_for_testing(clock); + role_map.destroy_initial_admin_capability(admin_cap); + role_map.destroy(); + ts::end(scenario); +} + +// ===== Tests: new_capability error path for nonexistent role ===== + +#[test] +#[expected_failure(abort_code = role_map::ERoleDoesNotExist)] +fun test_new_capability_fails_on_nonexistent_role() { + let admin_user = @0xAD; + let mut scenario = ts::begin(admin_user); + + let (mut role_map, admin_cap, _target_key) = test_utils::create_test_role_map( + ts::ctx(&mut scenario), + ); + + let clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); + + let bad_cap = role_map.new_capability( + &admin_cap, + &string::utf8(b"NonExistent"), + std::option::none(), + std::option::none(), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + iota::clock::destroy_for_testing(clock); + role_map.destroy_capability(bad_cap); + role_map.destroy_initial_admin_capability(admin_cap); + role_map.destroy(); + ts::end(scenario); +} + +// ===== Tests: destroy RoleMap with non-empty revoked capabilities ===== + +fun create_and_revoke_worker_capability( + role_map: &mut role_map::RoleMap, + admin_cap: &Capability, + clock: &iota::clock::Clock, + scenario: &mut ts::Scenario, +): Capability { + let worker_cap = role_map.new_capability( + admin_cap, + &string::utf8(b"Worker"), + std::option::none(), + std::option::none(), + std::option::none(), + clock, + ts::ctx(scenario), + ); + let worker_cap_id = worker_cap.id(); + role_map.revoke_capability( + admin_cap, + worker_cap_id, + std::option::none(), + clock, + ts::ctx(scenario), + ); + worker_cap +} + +#[test] +fun test_destroy_role_map_with_revoked_capabilities() { + let admin_user = @0xAD; + let mut scenario = ts::begin(admin_user); + + let (mut role_map, admin_cap, _target_key) = test_utils::create_test_role_map( + ts::ctx(&mut scenario), + ); + + let clock = iota::clock::create_for_testing(ts::ctx(&mut scenario)); + + // Create a role and capability, then revoke it + role_map.create_role( + &admin_cap, + string::utf8(b"Worker"), + vec_set::singleton(test_utils::action_a()), + std::option::none(), + &clock, + ts::ctx(&mut scenario), + ); + + let worker_cap_1 = create_and_revoke_worker_capability(&mut role_map, &admin_cap, &clock, &mut scenario); + let worker_cap_2 = create_and_revoke_worker_capability(&mut role_map, &admin_cap, &clock, &mut scenario); + let worker_cap_3 = create_and_revoke_worker_capability(&mut role_map, &admin_cap, &clock, &mut scenario); + + assert!(role_map.revoked_capabilities().length() == 3, 0); + + // Destroy the RoleMap while revoked_capabilities is non-empty + role_map.destroy(); + + // Cleanup the rest + transfer::public_transfer(admin_cap, admin_user); + transfer::public_transfer(worker_cap_1, admin_user); + transfer::public_transfer(worker_cap_2, admin_user); + transfer::public_transfer(worker_cap_3, admin_user); + + iota::clock::destroy_for_testing(clock); + ts::end(scenario); +} + +// ===== Tests: getter functions (target_key, role_admin_permissions) ===== + +#[test] +fun test_target_key_getter() { + let admin_user = @0xAD; + let mut scenario = ts::begin(admin_user); + + let (mut role_map, admin_cap, target_key) = test_utils::create_test_role_map( + ts::ctx(&mut scenario), + ); + + assert!(role_map.target_key() == target_key, 0); + + role_map.destroy_initial_admin_capability(admin_cap); + role_map.destroy(); + ts::end(scenario); +} + +#[test] +fun test_role_admin_permissions_getter() { + let admin_user = @0xAD; + let mut scenario = ts::begin(admin_user); + + let (mut role_map, admin_cap, _target_key) = test_utils::create_test_role_map( + ts::ctx(&mut scenario), + ); + + // Just verify it returns without abort — the getter was previously at 0% coverage + let _perms = role_map.role_admin_permissions(); + + role_map.destroy_initial_admin_capability(admin_cap); + role_map.destroy(); + ts::end(scenario); +} + From ad6f4d8fd75d618add0a6656e63743d91d37bca6 Mon Sep 17 00:00:00 2001 From: Yasir Date: Tue, 24 Mar 2026 17:20:27 +0300 Subject: [PATCH 12/18] chore: publish components move package --- components_move/Move.history.json | 4 ++ components_move/scripts/publish_package.sh | 47 ++++++++++++++++++++ product_common/src/lib.rs | 1 + product_common/src/tf_components_registry.rs | 22 +++++++++ 4 files changed, 74 insertions(+) create mode 100644 components_move/Move.history.json create mode 100755 components_move/scripts/publish_package.sh create mode 100644 product_common/src/tf_components_registry.rs diff --git a/components_move/Move.history.json b/components_move/Move.history.json new file mode 100644 index 0000000..5db7373 --- /dev/null +++ b/components_move/Move.history.json @@ -0,0 +1,4 @@ +{ + "aliases": {}, + "envs": {} +} diff --git a/components_move/scripts/publish_package.sh b/components_move/scripts/publish_package.sh new file mode 100755 index 0000000..692a308 --- /dev/null +++ b/components_move/scripts/publish_package.sh @@ -0,0 +1,47 @@ +#!/bin/bash + +# Copyright 2020-2026 IOTA Stiftung +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +if [[ "$#" -lt 1 || "$#" -gt 2 ]]; then + echo "usage: $0 [alias]" >&2 + exit 1 +fi + +chain_id="$1" +alias="${2:-}" + +script_dir=$(cd "$(dirname "$0")" && pwd) +package_dir="$script_dir/.." +history_file="$package_dir/Move.history.json" + +response=$(iota client publish --silence-warnings --json --gas-budget 500000000 "$package_dir") +package_id=$(echo "$response" | jq -r '.objectChanges[] | select(.type | contains("published")) | .packageId') + +tmp_file=$(mktemp) + +if [[ -n "$alias" ]]; then + jq \ + --arg chain_id "$chain_id" \ + --arg alias "$alias" \ + --arg package_id "$package_id" \ + ' + .aliases[$alias] = $chain_id + | .envs[$chain_id] = ((.envs[$chain_id] // []) + [$package_id] | unique) + ' \ + "$history_file" > "$tmp_file" +else + jq \ + --arg chain_id "$chain_id" \ + --arg package_id "$package_id" \ + ' + .envs[$chain_id] = ((.envs[$chain_id] // []) + [$package_id] | unique) + ' \ + "$history_file" > "$tmp_file" +fi + +mv "$tmp_file" "$history_file" + +echo "$package_id" diff --git a/product_common/src/lib.rs b/product_common/src/lib.rs index 92a317e..21936fe 100644 --- a/product_common/src/lib.rs +++ b/product_common/src/lib.rs @@ -15,6 +15,7 @@ pub mod move_history_manager; pub mod network_name; pub mod object; pub mod package_registry; +pub mod tf_components_registry; pub mod well_known_networks; #[cfg(feature = "transaction")] diff --git a/product_common/src/tf_components_registry.rs b/product_common/src/tf_components_registry.rs new file mode 100644 index 0000000..cb477c8 --- /dev/null +++ b/product_common/src/tf_components_registry.rs @@ -0,0 +1,22 @@ +// Copyright 2020-2026 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use std::sync::LazyLock; + +use iota_interaction::types::base_types::ObjectID; + +use crate::package_registry::PackageRegistry; + +static TF_COMPONENTS_PACKAGE_REGISTRY: LazyLock = LazyLock::new(|| { + let package_history_json = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../components_move/Move.history.json" + )); + + PackageRegistry::from_package_history_json_str(package_history_json) + .expect("TfComponents Move.history.json exists and is valid") +}); + +pub fn tf_components_package_id(network: &str) -> Option { + TF_COMPONENTS_PACKAGE_REGISTRY.package_id(network) +} From 43bc4e1abcb51a51acca56aa7ce04c82193a30f4 Mon Sep 17 00:00:00 2001 From: Yasir Date: Thu, 26 Mar 2026 08:31:15 +0300 Subject: [PATCH 13/18] chore: add tf component id to the trait --- components_move/sources/role_map.move | 83 ++++++++++++++++----------- product_common/src/core_client.rs | 8 +++ 2 files changed, 56 insertions(+), 35 deletions(-) diff --git a/components_move/sources/role_map.move b/components_move/sources/role_map.move index fa2871b..9879e6f 100644 --- a/components_move/sources/role_map.move +++ b/components_move/sources/role_map.move @@ -34,11 +34,13 @@ /// module tf_components::role_map; -use iota::clock::{Self, Clock}; -use iota::event; -use iota::vec_map::{Self, VecMap}; -use iota::vec_set::{Self, VecSet}; -use iota::linked_table::{Self, LinkedTable}; +use iota::{ + clock::{Self, Clock}, + event, + linked_table::{Self, LinkedTable}, + vec_map::{Self, VecMap}, + vec_set::{Self, VecSet} +}; use std::string::String; use tf_components::capability::{Self, Capability}; @@ -306,7 +308,7 @@ public fun destroy(self: RoleMap) { } = self; while (!revoked_capabilities.is_empty()) { - revoked_capabilities.pop_front(); + revoked_capabilities.pop_front(); }; revoked_capabilities.destroy_empty(); } @@ -573,11 +575,14 @@ public fun new_capability( /// /// Initial admin capabilities cannot be destroyed via this function. /// Use `destroy_initial_admin_capability` instead. -/// +/// /// Will remove the capability from the `revoked_capabilities` denylist if it's included. /// /// Sends a `CapabilityDestroyed` event upon successful destruction. -public fun destroy_capability(self: &mut RoleMap, cap_to_destroy: Capability) { +public fun destroy_capability( + self: &mut RoleMap, + cap_to_destroy: Capability, +) { assert!(self.target_key == cap_to_destroy.target_key(), ECapabilityTargetKeyMismatch); assert!( !self.initial_admin_cap_ids.contains(&cap_to_destroy.id()), @@ -595,7 +600,7 @@ public fun destroy_capability(self: &mut RoleMap 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(); } @@ -608,61 +613,61 @@ public fun destroy_capability(self: &mut RoleMap /// Use `revoke_initial_admin_capability` instead. /// /// * Sends a `CapabilityRevoked` event upon successful revocation. -/// +/// /// Off-chain tracking for issued capabilities /// ------------------------------------------ /// The `RoleMap` has been designed for users issuing high numbers of capabilities. -/// +/// /// It therefore uses a denylist to manage revoked capabilities. Inherently, a denylist doesn't allow /// to track all issued capabilities on-chain. Tracking issued capability ID's and their validity constraints /// on-chain would lead to high storage costs and would slow down capability validity checks. -/// +/// /// The `revoke_capability()` function therefore relies on the user to provide correct information about /// the capability to revoke, which is the main challenge of this approach: -/// +/// /// **Users of the `RoleMap` need to have an off-chain tracking mechanism for issued capabilities, /// their id's and optional constraints.** -/// +/// /// The main strength of this approach is that it allows to keep the internally managed denylist /// (`revoked_capabilities`) as short as possible, by only including capabilities that are actually /// revoked and haven't expired yet. -/// +/// /// To keep the denylist as short as possible, we recommend to: /// * Use the `Capability::valid_until` field to set an expiry date for issued capabilities whenever possible /// * Track the id and expiry date of issued capabilities off-chain /// * Set the `cap_to_revoke_valid_until` parameter (see below) to the `valid_until` value of the capability /// when revoking a capability /// * Frequently use the `cleanup_revoked_capabilities()` function to automatically remove expired capabilities from list -/// +/// /// Please note: Revoked capabilities without an expiry date need to be included in the list until they are explicitly /// destroyed. This means in case users miss to destroy capabilities, these capabilities will remain in the list infinitely. /// As there is no maximum size to be taken into account, this is not a problem per se but should be avoided. -/// +/// /// **Keeping the denylist as short as possible is crucial for users issuing high numbers of capabilities, -/// to avoid high storage costs and performance issues.** -/// +/// to avoid high storage costs and performance issues.** +/// /// See the `cap_to_revoke` parameter documentation below for the constraints that need to be fulfilled for the /// `revoke_capability()` function to work correctly. -/// +/// /// If users of the RoleMap only issue minor numbers of capabilities, they can also choose to set the /// `cap_to_revoke_valid_until` parameter to `option::none()` when revoking a capability. In this case, the off-chain /// tracking mechanism only needs to maintain a list of already issued capability ID's. -/// +/// /// Parameters /// ---------- /// - cap: Reference to the capability used to authorize the revocation of the `cap_to_revoke` capability. /// Needs to grant the `CapabilityAdminPermissions::revoke` permission. -/// - cap_to_revoke: +/// - cap_to_revoke: /// The capability to be revoked is specified by its ID (see above for more details). /// The user of this function is responsible to only pass `cap_to_revoke` values that meet the following preconditions: /// * A capability with the provided `cap_to_revoke` ID exists /// * The capability specified by `cap_to_revoke` has been issued by this RoleMap instance /// * The optional `valid_until` value of the capability specified by `cap_to_revoke` has not expired /// (in this case there would be no need to revoke it) -/// +/// /// To meet these preconditions, the off-chain tracking mechanism implemented by the user of the RoleMap is responsible for /// registering all issued capabilities, their ID's and optional expiry dates. -/// +/// /// **The `revoke_capability()` function itself will not evaluate any of the above listed checks.** /// /// If you provide i.e. random `cap_to_revoke` ID's they will be stored in the `revoked_capabilities` without any errors. @@ -672,7 +677,7 @@ public fun destroy_capability(self: &mut RoleMap /// by removing already expired capabilities from the list. /// - clock: Reference to a Clock instance for time-based validation. /// - ctx: Reference to the transaction context. -/// +/// /// Errors: /// - Aborts with any error documented by `assert_capability_valid` if the provided capability fails authorization checks. /// - Aborts with `ECapabilityToRevokeHasAlreadyBeenRevoked` if `cap_to_revoke` has already been revoked. @@ -708,7 +713,7 @@ public fun revoke_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. -/// +/// /// See function `revoke_capability` above for more details. /// /// Parameters @@ -760,7 +765,7 @@ public fun cleanup_revoked_capabilities( /// sealed with no admin access possible. /// /// Sends a `CapabilityDestroyed` event upon successful destruction. -/// +/// /// Will remove the capability from the `revoked_capabilities` denylist if it's included. /// /// Errors: @@ -777,7 +782,7 @@ public fun destroy_initial_admin_capability( ); if (self.revoked_capabilities.contains(cap_to_destroy.id())) { - self.revoked_capabilities.remove(cap_to_destroy.id()); + self.revoked_capabilities.remove(cap_to_destroy.id()); }; self.initial_admin_cap_ids.remove(&cap_to_destroy.id()); @@ -788,7 +793,7 @@ public fun destroy_initial_admin_capability( 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(); } @@ -802,8 +807,8 @@ public fun destroy_initial_admin_capability( /// sealed with no admin access possible. /// /// Sends a `CapabilityRevoked` event upon successful revocation. -/// -/// See function `revoke_capability()` for parameter documentation and further details. +/// +/// See function `revoke_capability()` for parameter documentation and further details. /// /// Errors: /// - Aborts with any error documented by `assert_capability_valid` if the provided capability fails authorization checks. @@ -847,7 +852,10 @@ fun has_required_admin_permissions( } /// Issues a new capability -fun issue_capability(self: &mut RoleMap, new_cap: &Capability) { +fun issue_capability( + self: &mut RoleMap, + new_cap: &Capability, +) { if (new_cap.role() == &self.initial_admin_role_name) { self.initial_admin_cap_ids.insert(new_cap.id()); }; @@ -866,9 +874,12 @@ fun issue_capability(self: &mut RoleMap, n fun add_cap_to_revoke_list( self: &mut RoleMap, cap_to_revoke: ID, - cap_to_revoke_valid_until: Option + cap_to_revoke_valid_until: Option, ) { - assert!(!self.revoked_capabilities.contains(cap_to_revoke), ECapabilityToRevokeHasAlreadyBeenRevoked); + assert!( + !self.revoked_capabilities.contains(cap_to_revoke), + ECapabilityToRevokeHasAlreadyBeenRevoked, + ); let valid_until = cap_to_revoke_valid_until.borrow_with_default(&0); self.revoked_capabilities.push_back(cap_to_revoke, *valid_until); @@ -899,6 +910,8 @@ public fun role_admin_permissions( &self.role_admin_permissions } -public fun revoked_capabilities(self: &RoleMap): &LinkedTable { +public fun revoked_capabilities( + self: &RoleMap, +): &LinkedTable { &self.revoked_capabilities } diff --git a/product_common/src/core_client.rs b/product_common/src/core_client.rs index fd04bdb..a26c1ac 100644 --- a/product_common/src/core_client.rs +++ b/product_common/src/core_client.rs @@ -16,6 +16,7 @@ use serde::de::DeserializeOwned; use crate::iota_interaction_adapter::IotaClientAdapter; use crate::network_name::NetworkName; +use crate::tf_components_registry; #[cfg_attr(not(feature = "send-sync"), async_trait(?Send))] #[cfg_attr(feature = "send-sync", async_trait)] @@ -37,6 +38,13 @@ pub trait CoreClientReadOnly { /// This allows access to lower-level client operations if needed. fn client_adapter(&self) -> &IotaClientAdapter; + /// Returns the [`TfComponents`] package ID for this client's network, if applicable. + /// + /// Products that do not depend on `TfComponents` can rely on the default implementation. + fn tf_components_package_id(&self) -> Option { + tf_components_registry::tf_components_package_id(self.network_name().as_ref()) + } + /// Returns the IDs of all packages version, from initial to current. fn package_history(&self) -> Vec { vec![self.package_id()] From 2cbbedcb4fb868fc6b23b3ad0f9c7de1ab08e086 Mon Sep 17 00:00:00 2001 From: Yasir Date: Fri, 27 Mar 2026 18:14:12 +0300 Subject: [PATCH 14/18] chore: bump up msrv --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 0ce44c0..3723897 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ edition = "2021" homepage = "https://www.iota.org" license = "Apache-2.0" repository = "https://github.com/iotaledger/product-core.rs" -rust-version = "1.70" +rust-version = "1.80" [workspace] resolver = "2" From c3e2c6355fae48dfeb84a79cd97c382514934c93 Mon Sep 17 00:00:00 2001 From: Yasir Date: Mon, 30 Mar 2026 11:50:23 +0300 Subject: [PATCH 15/18] Preserve tf components package id in wasm core client bridge --- .../iota_interaction_ts/lib/core_client.ts | 1 + .../iota_interaction_ts/src/core_client.rs | 3 +++ product_common/src/bindings/core_client.rs | 27 +++++++++++++++++++ 3 files changed, 31 insertions(+) diff --git a/bindings/wasm/iota_interaction_ts/lib/core_client.ts b/bindings/wasm/iota_interaction_ts/lib/core_client.ts index 782388d..7427db0 100644 --- a/bindings/wasm/iota_interaction_ts/lib/core_client.ts +++ b/bindings/wasm/iota_interaction_ts/lib/core_client.ts @@ -5,6 +5,7 @@ import { TransactionSigner } from "~iota_interaction_ts"; export interface CoreClientReadOnly { packageId(): string; packageHistory(): string[]; + tfComponentsPackageId(): string | undefined; network(): string; iotaClient(): IotaClient; } diff --git a/bindings/wasm/iota_interaction_ts/src/core_client.rs b/bindings/wasm/iota_interaction_ts/src/core_client.rs index 06289d7..240e52f 100644 --- a/bindings/wasm/iota_interaction_ts/src/core_client.rs +++ b/bindings/wasm/iota_interaction_ts/src/core_client.rs @@ -18,6 +18,9 @@ extern "C" { #[wasm_bindgen(method, js_name = packageHistory)] pub fn package_history(this: &WasmCoreClientReadOnly) -> Vec; + #[wasm_bindgen(method, js_name = tfComponentsPackageId)] + pub fn tf_components_package_id(this: &WasmCoreClientReadOnly) -> Option; + #[wasm_bindgen(method, js_name = network)] pub fn network(this: &WasmCoreClientReadOnly) -> String; diff --git a/product_common/src/bindings/core_client.rs b/product_common/src/bindings/core_client.rs index c9c70c7..c16b574 100644 --- a/product_common/src/bindings/core_client.rs +++ b/product_common/src/bindings/core_client.rs @@ -18,6 +18,7 @@ use crate::network_name::NetworkName; #[wasm_bindgen] pub struct WasmManagedCoreClientReadOnly { package_history: Vec, + tf_components_package_id: Option, network: NetworkName, iota_client_adapter: IotaClientAdapter, } @@ -30,11 +31,17 @@ impl WasmManagedCoreClientReadOnly { .map(|pkg_id| pkg_id.parse()) .collect::, ObjectIDParseError>>() .map_err(|e| JsError::new(&e.to_string()))?; + let tf_components_package_id = wasm_core_client + .tf_components_package_id() + .map(|pkg_id| pkg_id.parse()) + .transpose() + .map_err(|e: ObjectIDParseError| JsError::new(&e.to_string()))?; let network = wasm_core_client.network().parse().wasm_result()?; let iota_client_adapter = IotaClientAdapter::new(wasm_core_client.iota_client()); Ok(Self { package_history, + tf_components_package_id, network, iota_client_adapter, }) @@ -49,11 +56,13 @@ impl WasmManagedCoreClientReadOnly { C: CoreClientReadOnly, { let package_history = core_client.package_history(); + let tf_components_package_id = core_client.tf_components_package_id(); let network = core_client.network_name().clone(); let iota_client_adapter = core_client.client_adapter().clone(); Self { package_history, + tf_components_package_id, network, iota_client_adapter, } @@ -78,6 +87,11 @@ impl WasmManagedCoreClientReadOnly { self.package_history.iter().map(|pkg| pkg.to_string()).collect() } + #[wasm_bindgen(js_name = tfComponentsPackageId)] + pub fn tf_components_package_id(&self) -> Option { + self.tf_components_package_id.map(|pkg| pkg.to_string()) + } + #[wasm_bindgen] pub fn network(&self) -> String { self.network.to_string() @@ -98,6 +112,10 @@ impl CoreClientReadOnly for WasmManagedCoreClientReadOnly { self.package_history.clone() } + fn tf_components_package_id(&self) -> Option { + self.tf_components_package_id + } + fn network_name(&self) -> &NetworkName { &self.network } @@ -171,6 +189,11 @@ impl WasmManagedCoreClient { self.read_only.package_history() } + #[wasm_bindgen(js_name = tfComponentsPackageId)] + pub fn tf_components_package_id(&self) -> Option { + self.read_only.tf_components_package_id() + } + #[wasm_bindgen] pub fn network(&self) -> String { self.read_only.network.to_string() @@ -208,6 +231,10 @@ impl CoreClientReadOnly for WasmManagedCoreClient { CoreClientReadOnly::package_history(&self.read_only) } + fn tf_components_package_id(&self) -> Option { + CoreClientReadOnly::tf_components_package_id(&self.read_only) + } + fn network_name(&self) -> &NetworkName { &self.read_only.network } From 3c21bc8fd52477fb9a5b28a0b799efa73dce425a Mon Sep 17 00:00:00 2001 From: Yasir Date: Mon, 30 Mar 2026 12:24:54 +0300 Subject: [PATCH 16/18] Fix V2 dynamic field param name --- iota_interaction/src/sdk_types/generated_types.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/iota_interaction/src/sdk_types/generated_types.rs b/iota_interaction/src/sdk_types/generated_types.rs index 2e01d16..eff83d2 100644 --- a/iota_interaction/src/sdk_types/generated_types.rs +++ b/iota_interaction/src/sdk_types/generated_types.rs @@ -80,7 +80,7 @@ impl GetDynamicFieldObjectParams { #[serde(rename_all = "camelCase")] pub struct GetDynamicFieldObjectV2Params { /// The ID of the queried parent object - parent_id: String, + parent_object_id: String, /// The Name of the dynamic field name: DynamicFieldName, /// options for specifying the content to be returned @@ -89,9 +89,9 @@ pub struct GetDynamicFieldObjectV2Params { } impl GetDynamicFieldObjectV2Params { - pub fn new(parent_id: String, name: DynamicFieldName, options: Option) -> Self { + pub fn new(parent_object_id: String, name: DynamicFieldName, options: Option) -> Self { GetDynamicFieldObjectV2Params { - parent_id, + parent_object_id, name, options, } From 62a160d5e5b179e7fa30a65e64f843a593d341a8 Mon Sep 17 00:00:00 2001 From: Yasir Date: Mon, 30 Mar 2026 16:50:48 +0300 Subject: [PATCH 17/18] chore: deploy tf components to testnet --- components_move/Move.history.json | 12 +++++++++--- components_move/Move.lock | 12 ++++++++++-- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/components_move/Move.history.json b/components_move/Move.history.json index 5db7373..b51383b 100644 --- a/components_move/Move.history.json +++ b/components_move/Move.history.json @@ -1,4 +1,10 @@ { - "aliases": {}, - "envs": {} -} + "aliases": { + "testnet": "2304aa97" + }, + "envs": { + "testnet": [ + "0x098767e6cd008f341847ad68089300375a274899b1c718e8cf8f5d57f96e8607" + ] + } +} \ No newline at end of file diff --git a/components_move/Move.lock b/components_move/Move.lock index 05bd2b8..56a031c 100644 --- a/components_move/Move.lock +++ b/components_move/Move.lock @@ -42,6 +42,14 @@ dependencies = [ ] [move.toolchain-version] -compiler-version = "1.17.2" +compiler-version = "1.19.1" edition = "2024.beta" -flavor = "iota" \ No newline at end of file +flavor = "iota" + +[env] + +[env.testnet] +chain-id = "2304aa97" +original-published-id = "0x098767e6cd008f341847ad68089300375a274899b1c718e8cf8f5d57f96e8607" +latest-published-id = "0x098767e6cd008f341847ad68089300375a274899b1c718e8cf8f5d57f96e8607" +published-version = "1" From c010c8eed8e686c4121f28ec0c82808e2ef8355d Mon Sep 17 00:00:00 2001 From: Yasir Date: Wed, 1 Apr 2026 12:40:31 +0300 Subject: [PATCH 18/18] fix: re-export iota_move types from rpc_types --- iota_interaction/src/sdk_types/iota_json_rpc_types/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/iota_interaction/src/sdk_types/iota_json_rpc_types/mod.rs b/iota_interaction/src/sdk_types/iota_json_rpc_types/mod.rs index 47aa47d..3c7b9d8 100644 --- a/iota_interaction/src/sdk_types/iota_json_rpc_types/mod.rs +++ b/iota_interaction/src/sdk_types/iota_json_rpc_types/mod.rs @@ -12,6 +12,7 @@ pub use iota_transaction::*; pub use iota_object::*; pub use iota_coin::*; pub use iota_event::*; +pub use iota_move::*; use serde::{Deserialize, Serialize};