Problem
renounce_admin (src/governance.rs) clears the admin and the pending admin slot, but not the pending operator slot:
pub fn renounce_admin(env: &Env, caller: &Address) -> Result<(), SLAError> {
...
env.storage().instance().remove(&ADMIN_KEY);
env.storage().instance().remove(&PENDING_ADMIN_KEY);
env.events().publish((EVENT_ADMIN_REN, EVENT_VERSION, caller.clone()), ());
Ok(())
}
PENDING_OP_KEY is untouched. accept_operator requires only that the caller match the pending slot — it does not require an admin to exist.
Consequences:
- An adminless contract can still change its operator: after renounce, the pending operator calls
accept_operator and becomes operator; the contract now has no admin and a newly installed operator — a governance state the renounce flow never intended.
- The irreversible-renounce guarantee is incomplete: renounce is documented as "permanently renounce admin authority" and "admin-gated functions will be permanently locked", but the operator handoff path survives the renounce, so authority flows continue after the "permanent" lockout.
- No event or check makes the transition visible: nothing emits a signal that a pending operator proposal outlived the admin, and no guard prevents acceptance after renounce.
Root cause
renounce_admin was written to clean up admin-side state only; the cross-role invariant (renounce invalidates all pending governance) was missed.
Why this is architecturally hard
- The fix direction is a design decision: clear
PENDING_OP_KEY on renounce (with an event?), or make accept_operator/accept_admin require an existing admin (which changes the accept path's dependencies), or both.
accept_operator currently reads only PENDING_OP_KEY; requiring admin existence adds a storage read and a new failure mode (what happens to the pending slot if accept is blocked — it stays pending forever, which is the proposal-expiry companion issue).
- This is a trust-boundary issue: the state machine for "no admin" must be defined completely (which functions remain callable, which become permanently locked) and documented, since renounce is irreversible.
Acceptance criteria
Out of scope
Proposal expiry and the NotInitialized-after-renounce ambiguity (companion issue in batch 01/02).
Getting started
Good first files to read: apexchainx_calculator/src/governance.rs (renounce_admin, accept_operator), apexchainx_calculator/src/lib.rs (renounce_admin).
Problem
renounce_admin(src/governance.rs) clears the admin and the pending admin slot, but not the pending operator slot:PENDING_OP_KEYis untouched.accept_operatorrequires only that the caller match the pending slot — it does not require an admin to exist.Consequences:
accept_operatorand becomes operator; the contract now has no admin and a newly installed operator — a governance state the renounce flow never intended.Root cause
renounce_adminwas written to clean up admin-side state only; the cross-role invariant (renounce invalidates all pending governance) was missed.Why this is architecturally hard
PENDING_OP_KEYon renounce (with an event?), or makeaccept_operator/accept_adminrequire an existing admin (which changes the accept path's dependencies), or both.accept_operatorcurrently reads onlyPENDING_OP_KEY; requiring admin existence adds a storage read and a new failure mode (what happens to the pending slot if accept is blocked — it stays pending forever, which is the proposal-expiry companion issue).Acceptance criteria
renounce_admin, no pending proposal can complete a role change.Out of scope
Proposal expiry and the NotInitialized-after-renounce ambiguity (companion issue in batch 01/02).
Getting started
just testGood first files to read:
apexchainx_calculator/src/governance.rs(renounce_admin,accept_operator),apexchainx_calculator/src/lib.rs(renounce_admin).