Skip to content

Commit 5aff485

Browse files
fix(router-access): align expiry semantics and fix RoleMemberCount bookkeeping Closes #716 Three related bugs in contracts/router-access/src/lib.rs: 1. Expiry comparison inconsistency (>= vs >) is_role_expired and has_direct_role_internal used >= so a role whose expires_at equalled the current timestamp was already considered expired. router-core's is_route_expired uses >, making expires_at the last valid ledger sequence. Both sites changed to > to match that convention. 2. RoleMemberCount never incremented on grant grant_role_internal had a comment promising to maintain RoleMemberCount without iterating RoleMembers, but the increment was never written. Fixed by capturing currently_active before any writes, then incrementing only when !currently_active (new grant or re-grant after expiry). An expiry update on a live role does not increment to avoid double-counting. 3. Premature storage removal in expire_role caused count decrement to be skipped expire_role removed HasRole and RoleExpiry directly before delegating to deactivate_role_grant. Because deactivate_role_grant calls has_role_internal to decide whether to decrement the count, those early removes made it always see the role as inactive and skip the decrement. Removed the redundant removes so deactivate_role_grant is the single cleanup path for both expire_role and revoke_role. Added test_role_valid_at_exact_expiry_timestamp_expired_one_second_after to pin the new boundary semantics: role is valid when current == expires_at, expired only when current > expires_at. All 50 router-access tests pass.
1 parent 8fb26a3 commit 5aff485

1 file changed

Lines changed: 56 additions & 10 deletions

File tree

  • contracts/router-access/src

contracts/router-access/src/lib.rs

Lines changed: 56 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,12 @@ impl RouterAccess {
137137
}
138138

139139
/// Check if a role has expired for an address.
140+
///
141+
/// Returns `true` only when the current ledger timestamp **strictly exceeds**
142+
/// `expires_at`, matching the convention used throughout this suite:
143+
/// `expires_at` is the **last valid** timestamp, so the role is still active
144+
/// when `current_timestamp == expires_at` and expired only once
145+
/// `current_timestamp > expires_at`.
140146
pub fn is_role_expired(env: Env, role: String, target: Address) -> bool {
141147
// View helper: counter is maintained for active members, but expiry still
142148
// uses RoleExpiry storage.
@@ -147,7 +153,7 @@ impl RouterAccess {
147153
.get::<DataKey, u64>(&DataKey::RoleExpiry(role, target))
148154
{
149155
let current_timestamp = env.ledger().timestamp();
150-
current_timestamp >= expires_at
156+
current_timestamp > expires_at
151157
} else {
152158
false
153159
}
@@ -561,12 +567,6 @@ impl RouterAccess {
561567
) -> Result<(), AccessError> {
562568
caller.require_auth();
563569
router_common::require_admin_simple!(&env, &caller, &DataKey::SuperAdmin, AccessError)?;
564-
env.storage()
565-
.instance()
566-
.remove(&DataKey::RoleExpiry(role.clone(), target.clone()));
567-
env.storage()
568-
.instance()
569-
.remove(&DataKey::HasRole(role.clone(), target.clone()));
570570
Self::require_super_admin(&env, &caller)?;
571571
Self::deactivate_role_grant(&env, &role, &target);
572572
env.events().publish(
@@ -733,7 +733,8 @@ impl RouterAccess {
733733
// the requested expiry matches the existing expiry.
734734
//
735735
// This allows admins to extend/shorten expiry (or remove it by granting with `None`).
736-
if has_raw_assignment && Self::has_role_internal(env, account, role) {
736+
let currently_active = has_raw_assignment && Self::has_role_internal(env, account, role);
737+
if currently_active {
737738
let existing_expiry: Option<u64> = env
738739
.storage()
739740
.instance()
@@ -770,6 +771,22 @@ impl RouterAccess {
770771
.instance()
771772
.set(&DataKey::HasRole(role.clone(), account.clone()), &true);
772773

774+
// Increment RoleMemberCount when the account transitions from inactive to active.
775+
// This covers two cases:
776+
// 1. Brand-new grant (no prior assignment).
777+
// 2. Re-grant of a previously expired role (raw assignment exists but was inactive).
778+
// An expiry update on a live role must NOT increment to avoid double-counting.
779+
if !currently_active {
780+
let count: u32 = env
781+
.storage()
782+
.instance()
783+
.get::<DataKey, u32>(&DataKey::RoleMemberCount(role.clone()))
784+
.unwrap_or(0);
785+
env.storage()
786+
.instance()
787+
.set(&DataKey::RoleMemberCount(role.clone()), &(count + 1));
788+
}
789+
773790
let mut members: Vec<Address> = env
774791
.storage()
775792
.instance()
@@ -885,14 +902,17 @@ impl RouterAccess {
885902
return false;
886903
}
887904

888-
// Check if role has expired
905+
// Check if role has expired.
906+
// `expires_at` is the last valid timestamp: the role is still active when
907+
// `current_timestamp == expires_at` and only expired once it strictly
908+
// exceeds `expires_at`, consistent with `is_route_expired` in router-core.
889909
if let Some(expires_at) = env
890910
.storage()
891911
.instance()
892912
.get::<DataKey, u64>(&DataKey::RoleExpiry(role.clone(), account.clone()))
893913
{
894914
let current_timestamp = env.ledger().timestamp();
895-
if current_timestamp >= expires_at {
915+
if current_timestamp > expires_at {
896916
return false;
897917
}
898918
}
@@ -950,6 +970,32 @@ mod tests {
950970
assert!(!client.has_role(&user, &role));
951971
}
952972

973+
/// `expires_at` is the **last valid** timestamp: the role must still be
974+
/// active when `current_timestamp == expires_at` and only expired once
975+
/// `current_timestamp > expires_at`. This mirrors the semantics of
976+
/// `is_route_expired` in router-core and makes the boundary consistent
977+
/// across the entire suite.
978+
#[test]
979+
fn test_role_valid_at_exact_expiry_timestamp_expired_one_second_after() {
980+
let (env, admin, client) = setup();
981+
let role = String::from_str(&env, "operator");
982+
let user = Address::generate(&env);
983+
984+
let now = env.ledger().timestamp();
985+
client.grant_role(&admin, &user, &role, &Some(10));
986+
// expires_at == now + 10
987+
988+
// At exactly expires_at the role is still valid.
989+
env.ledger().set_timestamp(now + 10);
990+
assert!(client.has_role(&user, &role));
991+
assert!(!client.is_role_expired(&role, &user));
992+
993+
// One second past expires_at the role is expired.
994+
env.ledger().set_timestamp(now + 11);
995+
assert!(!client.has_role(&user, &role));
996+
assert!(client.is_role_expired(&role, &user));
997+
}
998+
953999
#[test]
9541000
fn test_set_role_admin_emits_event() {
9551001
let (env, admin, client) = setup();

0 commit comments

Comments
 (0)