Skip to content

fix(roles): reject revokeRole on sole admin (BOP-196)#91

Merged
amiecorso merged 2 commits into
mainfrom
ac/revoke-last-admin-guard
May 28, 2026
Merged

fix(roles): reject revokeRole on sole admin (BOP-196)#91
amiecorso merged 2 commits into
mainfrom
ac/revoke-last-admin-guard

Conversation

@amiecorso
Copy link
Copy Markdown
Collaborator

@amiecorso amiecorso commented May 28, 2026

Summary

Closes BOP-196.

revokeRole(DEFAULT_ADMIN_ROLE, soleAdmin) succeeded silently before this PR.

The dedicated renounceLastAdmin() path was intended to be the only legitimate way to remove the final admin — renounceRole already has the equivalent guard. revokeRole was missing it.

The fix

Mirror the existing renounceRole guard inside revokeRole:

function revokeRole(bytes32 role, address account) external onlyRoleAdmin(role) {
    MockB20Storage.Layout storage $ = MockB20Storage.layout();
    if (role == DEFAULT_ADMIN_ROLE && $.roles[DEFAULT_ADMIN_ROLE][account] && $.adminCount == 1) {
        revert LastAdminCannotRenounce();
    }
    _revokeRole(role, account);
}

Tests

  • test_revokeRole_revert_lastAdmin (NEW): regression coverage for the bug. The exact test that should have existed from day one — it would have caught this immediately. Fails without the fix, passes with it.
  • test_revokeRole_success_revokesNonSoleAdmin (NEW): pins the multi-admin-allowed case so the guard isn't over-restrictive.
  • Existing _success_ tests retain their vm.assume(!(role == DEFAULT_ADMIN_ROLE && account == admin)) exclusion (the case is now covered by the dedicated revert test), with natspec updated to reference the new guard instead of the old (incorrect) "would require renounceLastAdmin instead" reasoning.

How was this missed before?

Same pattern as the audit findings in PR #89: every existing test_revokeRole_success_* had vm.assume(!(role == DEFAULT_ADMIN_ROLE && account == admin)) — an exclusion based on the test author's incorrect mental model of the code. The comment even spelled out the wrong assumption: "would require renounceLastAdmin instead." The implementation enforced no such thing, and the vm.assume precisely excluded the bug-housing input from fuzz.

Without this guard, revokeRole(DEFAULT_ADMIN_ROLE, soleAdmin) succeeds silently: the admin role is cleared, adminCount drops to 0, and every future admin operation reverts unauthorized. No LastAdminRenounced event fires, so off-chain consumers see no signal that the token has reached its terminal admin-less state. Adds the same last-admin guard already present in renounceRole: when role == DEFAULT_ADMIN_ROLE, the target holds it, and adminCount == 1, revert LastAdminCannotRenounce. The renounceLastAdmin() path remains the only legitimate way to remove the final admin, and it emits the explicit terminal signal. Per Slack consensus from Conner: error when admin count is 1. Test added: test_revokeRole_revert_lastAdmin demonstrates the bug + verifies the fix; complemented by test_revokeRole_success_revokesNonSoleAdmin which pins the multi-admin-allowed case. Existing _success_ tests update their natspec to reference the new guard instead of the (previously-wrong) reasoning. Mock suite: 501/0/0. Note: the Rust precompile has the same bug — the new test currently fails in fork mode and will flip to passing once base/base ships the mirror fix (BOP-197, assigned to Andreas).
@linear
Copy link
Copy Markdown

linear Bot commented May 28, 2026

BOP-196

@amiecorso amiecorso changed the title fix(roles): reject revokeRole on sole admin (BOP-196) fix(roles): reject revokeRole on sole admin (BOP-196) May 28, 2026
Collapse short assertEq calls onto single lines per forge fmt. Fixes the CI format check on PR #91. Test behavior is unchanged.
Copy link
Copy Markdown
Collaborator

@ilikesymmetry ilikesymmetry left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! Can we confirm we also have this logic built in for renounceRole? renouceLastAdmin should be the only path to remove last admin

@refcell
Copy link
Copy Markdown
Contributor

refcell commented May 28, 2026

Thanks! Can we confirm we also have this logic built in for renounceRole? renouceLastAdmin should be the only path to remove last admin

renounceRole also reverts if the caller is the last admin. See here

@amiecorso amiecorso merged commit 9fa7651 into main May 28, 2026
7 checks passed
@amiecorso amiecorso deleted the ac/revoke-last-admin-guard branch May 28, 2026 18:01
amiecorso added a commit that referenced this pull request May 28, 2026
…createB20

Adds differential check-order tests for the remaining multi-precondition functions on the B20 surface: renounceRole, renounceLastAdmin, updatePolicy, updateSupplyCap, updateSecurityIdentifier, announce, and createB20 (STABLECOIN + SECURITY arms). All 13 new tests pass in mock and fork mode — no new Rust divergences surfaced. The 5 known divergences from the earlier commit are unchanged. revokeRole is the one multi-precondition function intentionally not covered here: its only interesting pair is ROLE vs LAST-ADMIN, but the LAST-ADMIN guard is being added in PR #91 (BOP-196) and the test will land alongside that fix to avoid a stranded mock-mode failure. Coverage now: 67 tests across 17 files. Fork: 62 pass / 5 fail (same 5 divergences as before).
amiecorso added a commit that referenced this pull request May 28, 2026
Now that PR #91 (BOP-196) has merged into main and through to this branch via the merge commit, the revokeRole LAST-ADMIN guard exists in MockB20. Adds the deferred ROLE-vs-LAST-ADMIN pair test. Both mock and fork pass: in both backends the role-admin check fires before the body's last-admin check, so the canonical ordering holds. Coverage now: 68 tests across 18 files. Fork: 63 pass / 5 fail (same 5 long-known divergences).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants