Problem
Two operator-change paths coexist in src/governance.rs:
// Two-step: propose_operator -> accept_operator (writes PENDING_OP_KEY)
// Single-step: set_operator (writes OPERATOR_KEY directly, admin only)
pub fn set_operator(env: &Env, caller: &Address, new_operator: &Address) -> Result<(), SLAError> {
...
env.storage().instance().set(&OPERATOR_KEY, new_operator);
env.events().publish((EVENT_OP_SET, EVENT_VERSION, caller.clone()), (new_operator.clone(),));
Ok(())
}
set_operator does not clear PENDING_OP_KEY. If a proposal is pending, a direct set_operator installs a new operator while the pending slot still names someone else; that someone can later call accept_operator and silently replace the operator that was just set directly.
Consequences:
- A stale proposal can override a direct assignment: the sequence set_operator(B) → (later) accept_operator(C, pending from before) leaves C as operator, undoing the admin's direct decision — with no event ordering that explains it.
- The two-step protocol is bypassable and then re-assertable:
set_operator exists as a "single-step, admin only" escape hatch, but its failure to clear the pending slot lets the two-step path fire after the direct one, producing an operator the admin never intended.
- Audit trails cannot reconcile the two paths:
op_set and op_acc events are independent; nothing links a direct assignment to the pending proposal it should have invalidated.
Root cause
set_operator and the proposal lifecycle were written independently; the invariant "a direct assignment invalidates any pending proposal" was never encoded.
Why this is architecturally hard
- The fix (clear
PENDING_OP_KEY in set_operator, possibly emitting op_can) is small, but it changes event behavior and must be paired with the state-machine definition from the propose-side companion issue.
- There is a design question whether
set_operator should even exist alongside the two-step path (the event schema documents both, but only the two-step path has cancel semantics); the issue should decide the canonical path.
- Tests must cover the interleaving (pending → direct set → accept) and pin the resulting operator and event stream.
Acceptance criteria
Out of scope
Removing set_operator entirely and the admin-side blind set (companion issues).
Getting started
Good first files to read: apexchainx_calculator/src/governance.rs (set_operator, propose_operator, accept_operator).
Problem
Two operator-change paths coexist in
src/governance.rs:set_operatordoes not clearPENDING_OP_KEY. If a proposal is pending, a directset_operatorinstalls a new operator while the pending slot still names someone else; that someone can later callaccept_operatorand silently replace the operator that was just set directly.Consequences:
set_operatorexists as a "single-step, admin only" escape hatch, but its failure to clear the pending slot lets the two-step path fire after the direct one, producing an operator the admin never intended.op_setandop_accevents are independent; nothing links a direct assignment to the pending proposal it should have invalidated.Root cause
set_operatorand the proposal lifecycle were written independently; the invariant "a direct assignment invalidates any pending proposal" was never encoded.Why this is architecturally hard
PENDING_OP_KEYinset_operator, possibly emittingop_can) is small, but it changes event behavior and must be paired with the state-machine definition from the propose-side companion issue.set_operatorshould even exist alongside the two-step path (the event schema documents both, but only the two-step path has cancel semantics); the issue should decide the canonical path.Acceptance criteria
set_operatorinvalidates any pending operator proposal (cleared or rejected).Out of scope
Removing
set_operatorentirely and the admin-side blind set (companion issues).Getting started
just testGood first files to read:
apexchainx_calculator/src/governance.rs(set_operator,propose_operator,accept_operator).