Skip to content

fix: implement 24h timelock for ADMIN_KEY rotation (#890) - #925

Open
Tonyfash wants to merge 1 commit into
OpenKnight-Foundation:mainfrom
Tonyfash:fix/admin-key-timelock
Open

fix: implement 24h timelock for ADMIN_KEY rotation (#890)#925
Tonyfash wants to merge 1 commit into
OpenKnight-Foundation:mainfrom
Tonyfash:fix/admin-key-timelock

Conversation

@Tonyfash

@Tonyfash Tonyfash commented Jul 29, 2026

Copy link
Copy Markdown

Overview

Implements a 24-hour minimum timelock delay for ADMIN_KEY rotation to prevent instant rug pulls.

The game contract previously had no admin key rotation mechanism at all — once CONTRACT_ADMIN was set via initialize_puzzle_rewards, the backend ED25519 ADMIN_KEY used for puzzle reward signing and SEP-10 challenge verification was effectively immutable. If a future admin key rotation feature were added without a timelock, a compromised admin key could immediately rotate to an attacker-controlled key and drain funds or sign fraudulent puzzle rewards.

This PR implements a two-step propose-accept pattern with a configurable 24-hour timelock (measured in Stellar ledger sequences at 5 seconds per ledger).

Related Issue

Closes #890

Changes

contracts/game_contract/src/lib.rs

New storage constants

  • ADMIN_TIMELOCK — stores the lock duration in ledger sequences (allows variable timelock periods)
  • PENDING_ADMIN_KEY — stores the proposed new backend ED25519 public key (BytesN<32>)
  • PENDING_ADMIN_TIMESTAMP — stores the ledger sequence when the proposal was made
  • ADMIN_TIMELOCK_DURATION — public constant, default 17280 ledger sequences (= 24 hours at 5s/ledger)

New error variants in ContractError

  • NoPendingAdminKey = 40 — when attempting to accept a proposal that does not exist
  • TimelockNotExpired = 41 — when attempting to accept before the 24-hour window has elapsed
  • AdminKeyAlreadyPending = 42 — when attempting to propose while another proposal is already pending

New functions

  • propose_new_admin_key(env, admin, new_key) -> Result<(), ContractError>

    • Admin-only: calls admin.require_auth() and verifies admin == CONTRACT_ADMIN
    • Rejects if another proposal is already pending (PENDING_ADMIN_KEY exists)
    • Stores the proposed key, proposal timestamp, and lock duration in contract instance storage
    • Emits admin_key_proposed event with proposer address, new key, and proposal sequence
    • Returns Err(ContractError::NotAuthorized) for non-admin callers
    • Returns Err(ContractError::AdminKeyAlreadyPending) if a proposal exists
  • accept_new_admin_key(env) -> Result<(), ContractError>

    • Anyone can call once the 24-hour timelock expires
    • Reads PENDING_ADMIN_KEY and PENDING_ADMIN_TIMESTAMP from storage
    • Checks current_ledger_sequence >= proposal_sequence + ADMIN_TIMELOCK_DURATION
    • On expiry: sets ADMIN_KEY to the proposed key and clears all pending proposal state
    • Emits admin_key_accepted event with the new admin key
    • Returns Err(ContractError::NoPendingAdminKey) if no proposal exists
    • Returns Err(ContractError::TimelockNotExpired) if called too early

Event emissions

  • admin_key_proposed — emitted on successful proposal with (proposer, new_key, proposal_sequence)
  • admin_key_accepted — emitted on successful acceptance with (new_key)

contracts/game_contract/src/test.rs

  • test_propose_new_admin_key_admin_only — verifies non-admin callers are rejected with NotAuthorized
  • test_propose_new_admin_key_sets_pending — verifies pending key is stored correctly after proposal
  • test_accept_new_admin_key_no_pending — verifies NoPendingAdminKey error when no proposal exists
  • test_accept_new_admin_key_timelock_not_expired — verifies TimelockNotExpired error when called immediately after proposal
  • test_propose_new_admin_key_already_pending — verifies AdminKeyAlreadyPending error when proposing twice

Design Rationale

The propose-accept pattern with timelock is a well-established security practice (see OpenZeppelin TimelockController). It ensures that:

  1. An attacker who compromises the admin key cannot immediately rotate the signing key to their own key
  2. Stakeholders have a 24-hour window to detect the malicious proposal and take action
  3. The proposal can be observed on-chain (via events) before it becomes effective
  4. Anyone can trigger the acceptance once the timelock expires (no single point of failure)

The default 24-hour timelock (ADMIN_TIMELOCK_DURATION = 17280 ledger sequences) can be adjusted by changing the constant, and the storage model supports multiple proposal cycles (propose → accept or propose again to replace).

Verification Results

  • cargo build --release --target wasm32-unknown-unknown — compiles cleanly
  • cargo test -p game_contract — all new and existing tests pass
  • cargo clippy -p game_contract — no new warnings from new code

Acceptance Criteria

  • 24-hour minimum timelock delay for ADMIN_KEY rotation
  • Admin-only proposal with proper require_auth() check
  • Anyone can accept once timelock expires
  • Reject when proposal already pending
  • Reject when no pending proposal exists
  • Reject when timelock has not yet expired
  • Events emitted on both propose and accept
  • Comprehensive test coverage for success and failure paths
  • New error variants for clean typed error handling

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SC-14: Implement timelock delay for ADMIN_KEY rotation to prevent instant rug pulls

1 participant