Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions apexchainx_calculator/src/governance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
Expand Down
54 changes: 54 additions & 0 deletions apexchainx_calculator/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.is_empty() {
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
// ============================================================
Expand Down
Loading