@@ -191,6 +191,12 @@ impl RouterAccess {
191191 }
192192
193193 /// Check if a role has expired for an address.
194+ ///
195+ /// Returns `true` only when the current ledger timestamp **strictly exceeds**
196+ /// `expires_at`, matching the convention used throughout this suite:
197+ /// `expires_at` is the **last valid** timestamp, so the role is still active
198+ /// when `current_timestamp == expires_at` and expired only once
199+ /// `current_timestamp > expires_at`.
194200 pub fn is_role_expired ( env : Env , role : String , target : Address ) -> bool {
195201 // View helper: counter is maintained for active members, but expiry still
196202 // uses RoleExpiry storage.
@@ -201,7 +207,7 @@ impl RouterAccess {
201207 . get :: < DataKey , u64 > ( & DataKey :: RoleExpiry ( role, target) )
202208 {
203209 let current_timestamp = env. ledger ( ) . timestamp ( ) ;
204- current_timestamp >= expires_at
210+ current_timestamp > expires_at
205211 } else {
206212 false
207213 }
@@ -615,12 +621,6 @@ impl RouterAccess {
615621 ) -> Result < ( ) , AccessError > {
616622 caller. require_auth ( ) ;
617623 router_common:: require_admin_simple!( & env, & caller, & DataKey :: SuperAdmin , AccessError ) ?;
618- env. storage ( )
619- . instance ( )
620- . remove ( & DataKey :: RoleExpiry ( role. clone ( ) , target. clone ( ) ) ) ;
621- env. storage ( )
622- . instance ( )
623- . remove ( & DataKey :: HasRole ( role. clone ( ) , target. clone ( ) ) ) ;
624624 Self :: require_super_admin ( & env, & caller) ?;
625625 Self :: deactivate_role_grant ( & env, & role, & target) ;
626626 env. events ( ) . publish (
@@ -809,7 +809,8 @@ impl RouterAccess {
809809 // the requested expiry matches the existing expiry.
810810 //
811811 // This allows admins to extend/shorten expiry (or remove it by granting with `None`).
812- if has_raw_assignment && Self :: has_role_internal ( env, account, role) {
812+ let currently_active = has_raw_assignment && Self :: has_role_internal ( env, account, role) ;
813+ if currently_active {
813814 let existing_expiry: Option < u64 > = env
814815 . storage ( )
815816 . instance ( )
@@ -846,6 +847,22 @@ impl RouterAccess {
846847 . instance ( )
847848 . set ( & DataKey :: HasRole ( role. clone ( ) , account. clone ( ) ) , & true ) ;
848849
850+ // Increment RoleMemberCount when the account transitions from inactive to active.
851+ // This covers two cases:
852+ // 1. Brand-new grant (no prior assignment).
853+ // 2. Re-grant of a previously expired role (raw assignment exists but was inactive).
854+ // An expiry update on a live role must NOT increment to avoid double-counting.
855+ if !currently_active {
856+ let count: u32 = env
857+ . storage ( )
858+ . instance ( )
859+ . get :: < DataKey , u32 > ( & DataKey :: RoleMemberCount ( role. clone ( ) ) )
860+ . unwrap_or ( 0 ) ;
861+ env. storage ( )
862+ . instance ( )
863+ . set ( & DataKey :: RoleMemberCount ( role. clone ( ) ) , & ( count + 1 ) ) ;
864+ }
865+
849866 let mut members: Vec < Address > = env
850867 . storage ( )
851868 . instance ( )
@@ -971,14 +988,17 @@ impl RouterAccess {
971988 return false ;
972989 }
973990
974- // Check if role has expired
991+ // Check if role has expired.
992+ // `expires_at` is the last valid timestamp: the role is still active when
993+ // `current_timestamp == expires_at` and only expired once it strictly
994+ // exceeds `expires_at`, consistent with `is_route_expired` in router-core.
975995 if let Some ( expires_at) = env
976996 . storage ( )
977997 . instance ( )
978998 . get :: < DataKey , u64 > ( & DataKey :: RoleExpiry ( role. clone ( ) , account. clone ( ) ) )
979999 {
9801000 let current_timestamp = env. ledger ( ) . timestamp ( ) ;
981- if current_timestamp >= expires_at {
1001+ if current_timestamp > expires_at {
9821002 return false ;
9831003 }
9841004 }
@@ -1036,6 +1056,32 @@ mod tests {
10361056 assert ! ( !client. has_role( & user, & role) ) ;
10371057 }
10381058
1059+ /// `expires_at` is the **last valid** timestamp: the role must still be
1060+ /// active when `current_timestamp == expires_at` and only expired once
1061+ /// `current_timestamp > expires_at`. This mirrors the semantics of
1062+ /// `is_route_expired` in router-core and makes the boundary consistent
1063+ /// across the entire suite.
1064+ #[ test]
1065+ fn test_role_valid_at_exact_expiry_timestamp_expired_one_second_after ( ) {
1066+ let ( env, admin, client) = setup ( ) ;
1067+ let role = String :: from_str ( & env, "operator" ) ;
1068+ let user = Address :: generate ( & env) ;
1069+
1070+ let now = env. ledger ( ) . timestamp ( ) ;
1071+ client. grant_role ( & admin, & user, & role, & Some ( 10 ) ) ;
1072+ // expires_at == now + 10
1073+
1074+ // At exactly expires_at the role is still valid.
1075+ env. ledger ( ) . set_timestamp ( now + 10 ) ;
1076+ assert ! ( client. has_role( & user, & role) ) ;
1077+ assert ! ( !client. is_role_expired( & role, & user) ) ;
1078+
1079+ // One second past expires_at the role is expired.
1080+ env. ledger ( ) . set_timestamp ( now + 11 ) ;
1081+ assert ! ( !client. has_role( & user, & role) ) ;
1082+ assert ! ( client. is_role_expired( & role, & user) ) ;
1083+ }
1084+
10391085 #[ test]
10401086 fn test_set_role_admin_emits_event ( ) {
10411087 let ( env, admin, client) = setup ( ) ;
0 commit comments