From 27e3dec20e36de0175d2bc345db2fd30ad0d6899 Mon Sep 17 00:00:00 2001 From: beulah7717108-eng Date: Sun, 30 Aug 2026 17:50:54 +0000 Subject: [PATCH 1/2] fix(governance): clear pending operator proposal on renounce_admin renounce_admin cleared the admin and pending-admin slots but left a pending operator handoff alive, so a stale operator could still call accept_operator and install an operator on an adminless contract. It now clears the pending operator slot (and its expiry timestamp) and emits an op_can event to signal the invalidation, so no pending proposal can complete a role change after renounce. Adds coverage for the clear and for the blocked acceptance path. Closes #470 --- CHANGELOG.md | 1 + apexchainx_calculator/src/governance.rs | 8 ++++ apexchainx_calculator/src/tests.rs | 54 +++++++++++++++++++++++++ 3 files changed, 63 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ee7a69..1b3b3ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ## [Unreleased] ### Changed +- **`renounce_admin` now invalidates a pending operator proposal.** Previously renounce cleared the admin and pending-admin slots but left `PENDING_OP_KEY` untouched, so a stale operator handoff could still complete on an adminless contract. Renounce now clears the pending operator slot (and its timestamp) and emits an `op_can` event when one was pending, so no pending proposal can complete a role change after renounce (#470) - **Fuzz targets now assert the contract's documented semantics, not just panic-freedom.** `compute_result` and `validate_config` previously ran the function and let libFuzzer watch for a crash; on code guarded throughout by `checked_mul`/`checked_neg` that finds essentially nothing, and a semantic regression (e.g. treating `mttr == threshold` as a violation) would have left the nightly job green. Both targets now compare every input against `apexchainx_calculator::spec` and fail on any disagreement. Each target header states what it asserts and what it does not; `docs/FUZZING_GUARANTEES.md` states the suite-wide guarantees and the policy for resolving an implementation-vs-documentation conflict - **`ts/historyPagination.ts` capped pages at 50 where the contract caps at 200** (`history::MAX_PAGE_SIZE`, #409), so a backend paging with `limit = 200` received 50 entries and — because the mirror also derived `hasMore` from the returned length — could conclude history had ended. It also coerced `limit = 0` up to 1, returning an entry where the contract returns an empty page, and reported `hasMore: false` where the contract reports `true`. The helper now imports the contract-generated `MAX_PAGE_SIZE` and mirrors `end = min(offset + limit, total)` / `hasMore = end < total` exactly - **`ts/configVersionHash.ts` computed an unrelated hash.** It ran djb2 over a canonical JSON serialisation of a snapshot whose fields (`penaltyBps`, `rewardBps`) do not exist on the contract, so a backend comparing it against `get_config_version_hash` would have seen a mismatch on every call. It now reproduces the contract's polynomial rolling hash exactly, in `BigInt` `u64` arithmetic, and is asserted equal to a contract-recorded value diff --git a/apexchainx_calculator/src/governance.rs b/apexchainx_calculator/src/governance.rs index 23424fc..2fb4cfd 100644 --- a/apexchainx_calculator/src/governance.rs +++ b/apexchainx_calculator/src/governance.rs @@ -159,6 +159,14 @@ pub fn renounce_admin(env: &Env, caller: &Address) -> Result<(), SLAError> { env.storage().instance().remove(&ADMIN_KEY); env.storage().instance().remove(&PENDING_ADMIN_KEY); env.storage().instance().remove(&PENDING_ADMIN_TS_KEY); + if env.storage().instance().has(&PENDING_OP_KEY) { + // Renounce must invalidate every pending governance proposal: an + // adminless contract must not allow a stale operator handoff to fire. + env.storage().instance().remove(&PENDING_OP_KEY); + env.storage().instance().remove(&PENDING_OP_TS_KEY); + env.events() + .publish((EVENT_OP_CAN, EVENT_VERSION, caller.clone()), ()); + } env.events() .publish((EVENT_ADMIN_REN, EVENT_VERSION, caller.clone()), ()); Ok(()) diff --git a/apexchainx_calculator/src/tests.rs b/apexchainx_calculator/src/tests.rs index 32b76b9..6ed6017 100644 --- a/apexchainx_calculator/src/tests.rs +++ b/apexchainx_calculator/src/tests.rs @@ -4653,6 +4653,60 @@ fn test_multiple_replacement_cycles_end_state_is_correct() { assert_eq!(client.get_pending_operator(), None); } +// ============================================================ +// #470 – renounce_admin invalidates a pending operator proposal +// ============================================================ + +/// Returns how many events named `name` the contract has emitted so far. +fn count_named_event(env: &Env, client: &SLACalculatorContractClient<'static>, name: &str) -> usize { + let mut count = 0; + let events = env.events().all(); + for i in 0..events.len() { + let (contract_id, topics, _) = events.get(i).unwrap(); + if contract_id != client.address { + continue; + } + if topics.len() >= 1 { + let topic0: Symbol = topics.get(0).unwrap().try_into_val(env).unwrap(); + if topic0 == symbol(env, name) { + count += 1; + } + } + } + count +} + +#[test] +fn test_renounce_admin_clears_pending_operator_proposal() { + // Renounce must invalidate a pending operator proposal too; otherwise the + // pending candidate could install an operator on an adminless contract. + let (env, client, actors) = setup(); + let pending_op = soroban_sdk::Address::generate(&env); + + client.propose_operator(&actors.admin, &pending_op); + assert_eq!(client.get_pending_operator(), Some(pending_op.clone())); + + client.renounce_admin(&actors.admin); + + assert_eq!(client.get_pending_operator(), None); + // Invalidation is signalled via an `op_can` event. + assert_eq!(count_named_event(&env, &client, "op_can"), 1); +} + +#[test] +#[should_panic] +fn test_operator_cannot_accept_after_renounce_with_pending() { + // After renounce with a pending operator handoff, the pending candidate + // must not be able to accept. + let (env, client, actors) = setup(); + let pending_op = soroban_sdk::Address::generate(&env); + + client.propose_operator(&actors.admin, &pending_op); + client.renounce_admin(&actors.admin); + + client.accept_operator(&pending_op); // must panic +} + // ============================================================ // #147 – Admin renounce preconditions // ============================================================ From b48ad7797389495ae17218274bd4511110c75b67 Mon Sep 17 00:00:00 2001 From: beulah7717108-eng Date: Sun, 30 Aug 2026 18:05:53 +0000 Subject: [PATCH 2/2] fix(clippy): use is_empty() instead of len() >= 1 in event-count helper Triggers clippy::len_zero under -D warnings in the Client Checks gate. --- apexchainx_calculator/src/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apexchainx_calculator/src/tests.rs b/apexchainx_calculator/src/tests.rs index 6ed6017..b0b9c1a 100644 --- a/apexchainx_calculator/src/tests.rs +++ b/apexchainx_calculator/src/tests.rs @@ -4666,7 +4666,7 @@ fn count_named_event(env: &Env, client: &SLACalculatorContractClient<'static>, n if contract_id != client.address { continue; } - if topics.len() >= 1 { + if !topics.is_empty() { let topic0: Symbol = topics.get(0).unwrap().try_into_val(env).unwrap(); if topic0 == symbol(env, name) { count += 1;