Problem
propose_admin (src/governance.rs) performs no check that new_admin != caller (the current admin):
pub fn propose_admin(env: &Env, caller: &Address, new_admin: &Address) -> Result<(), SLAError> {
...
env.storage().instance().set(&PENDING_ADMIN_KEY, new_admin);
env.events().publish((EVENT_ADMIN_PROP, EVENT_VERSION, caller.clone()), (new_admin.clone(),));
Ok(())
}
Proposing the current admin (or yourself) creates a pending slot equal to the current admin; the admin then calls accept_admin (passes the equality check against pending) and nothing changes — but two governance events were emitted for a no-op.
Consequences:
- The audit trail records phantom transitions:
adm_prop + adm_acc for a role that never changed; a compliance review counting role changes over-counts.
- The two-step protocol is reducible to a no-op ceremony: an admin can "confirm" their own role via the full propose/accept flow, which is indistinguishable in events from a real handoff followed by a rename back.
- The pending slot becomes a trap: after a self-proposal, a later legitimate proposal silently overwrites it (companion issue), and any code reading
get_pending_admin sees the admin themselves.
Root cause
The proposal path validates only that the caller is admin; it never validates that the proposal is a change.
Why this is architecturally hard
- Rejecting self-proposals (a new
InvalidInput-style error or reuse of an existing code) is a small guard, but it must be paired with the decision on whether accept_admin should also reject accepting when pending == current admin (defense in depth).
- The same no-op possibility exists on the operator side (
propose_operator with new_operator == current operator); the fix should cover both roles for consistency.
- Error-code choice is an ABI decision (companion error-catalog issue); alternatively the guard can be a silent no-op, but then the events must not be emitted — which is also a behavior change.
Acceptance criteria
Out of scope
The overwrite-supersession behavior (companion issue) and proposal expiry.
Getting started
Good first files to read: apexchainx_calculator/src/governance.rs (propose_admin, accept_admin).
Problem
propose_admin(src/governance.rs) performs no check thatnew_admin != caller(the current admin):Proposing the current admin (or yourself) creates a pending slot equal to the current admin; the admin then calls
accept_admin(passes the equality check against pending) and nothing changes — but two governance events were emitted for a no-op.Consequences:
adm_prop+adm_accfor a role that never changed; a compliance review counting role changes over-counts.get_pending_adminsees the admin themselves.Root cause
The proposal path validates only that the caller is admin; it never validates that the proposal is a change.
Why this is architecturally hard
InvalidInput-style error or reuse of an existing code) is a small guard, but it must be paired with the decision on whetheraccept_adminshould also reject accepting whenpending == current admin(defense in depth).propose_operatorwithnew_operator == current operator); the fix should cover both roles for consistency.Acceptance criteria
accept_admin/accept_operatorcannot complete a no-op transition.Out of scope
The overwrite-supersession behavior (companion issue) and proposal expiry.
Getting started
just testGood first files to read:
apexchainx_calculator/src/governance.rs(propose_admin,accept_admin).