Skip to content

feat(stellar): auction admin rotation via multisig + timelock - #178

Merged
truthixify merged 1 commit into
wraith-protocol:developfrom
DSOTec:feat/auction-admin-rotation
Aug 28, 2026
Merged

feat(stellar): auction admin rotation via multisig + timelock#178
truthixify merged 1 commit into
wraith-protocol:developfrom
DSOTec:feat/auction-admin-rotation

Conversation

@DSOTec

@DSOTec DSOTec commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Problem

AuctionConfig.admin is set once at init_auctions and there is no rotation path. If the auction operator key is lost or compromised, the only remedy is the WASM upgrade entrypoint, which is not implemented (wraith-names/tests/upgrade_auth.rs still carries #[ignore] cases). The rest of wraith-names already has the 7-day multisig rotation flow from Wave 7 #5, so auctions should reuse it.

Solution

Auction-admin rotation reuses the existing governance signer set, quorum, and ROTATION_TIMELOCK_SECS (7 days) from src/multisig.rs, under its own proposal slot so a signer rotation and an admin rotation can be in flight at the same time:

Step Function
Propose propose_rotate_auction_admin(caller, new_admin) — auto-approves the caller
Approve approve_rotate_auction_admin(caller)
Execute execute_rotate_auction_admin(caller) — after quorum + timelock
Cancel cancel_rotate_auction_admin(caller)
Inspect pending_auction_admin_rotation(), auctions_pending_settlement()

The issue names a single rotate_auction_admin(new_admin); a 7-day timelock needs a propose/execute split, so the surface is named to match the existing *_rotate_signers entrypoints. RotationProposal carries a signer set, so the flow is reused via a mirrored AdminRotationProposal { new_admin, executable_at, approvals } and a shared require_executable quorum+timelock gate that execute_rotate_signers now also uses.

Event. Execution emits AuctionAdminRotated with topics ("AuctionAdminRotated",) and data (old_admin, new_admin), matching the SignersRotated convention.

Phase guard. Rotation is rejected with AuctionInProgress while any auction has a revealed winner and has not settled — its reveal phase and the settle phase that follows. A new instance counter (AuctionKey::PendingSettlements) is incremented by the reveal that first gives an auction a winner and decremented at settlement, so the check is O(1) rather than a scan over auctions.

Two deliberate choices worth review:

  • Auctions with no reveals do not block. Counting every unsettled auction would have made rotation grief-able: start_auction is permissionless and free, so anyone could keep opening bidless auctions to indefinitely block an emergency rotation. Counting only auctions with a revealed winner means blocking requires real capital at stake, and it matches "reveal or settle phase" in the sense that matters — value owed to a treasury or a winner.
  • A blocked execution leaves the proposal intact, so the 7-day timelock does not restart. Settlement is permissionless, so governance can always settle the blocking auctions and re-run execute.

New error codes use the reserved wraith-names range per stellar/ERRORS.md: NamesError::AuctionsNotInitialized = 1600, NamesError::AuctionInProgress = 1601. Consistent with execute_rotate_signers (which does not require incoming signers to auth), the incoming admin is not required to co-sign.

Testing

cargo test -p wraith-names --test auction — 20 passing (12 pre-existing + 8 new):

  • auction_admin_rotation_happy_path — full flow, then asserts the AuctionAdminRotated contract id, topics, and (old, new) data
  • auction_admin_rotation_enforces_quorum_and_timelockNotSigner, RotationAlreadyPending, QuorumNotMet, AlreadyApprovedRotation
  • auction_admin_rotation_timelock_not_elapsed — rejected at T-1s, succeeds at T
  • auction_admin_rotation_blocked_during_reveal_and_settle — rejected during an active reveal and during the unsettled settle phase, proposal survives both, succeeds after settle_auction
  • auction_admin_rotation_not_blocked_by_unrevealed_auction — bidless auction does not block; the deposit is still refundable in full
  • auction_admin_rotation_cancel — cancel clears state, fresh proposal restarts the timelock
  • auction_admin_rotation_requires_initializationMultisigNotInitialized, AuctionsNotInitialized, NoPendingRotation
  • auction_admin_rotation_independent_of_signer_rotation — a pending signer rotation is neither consumed nor blocked

Also verified: cargo test --workspace (0 failures), cargo fmt --all --check, and the CI ERRORS.md catalog check run locally against the new variants.

Test snapshots: only the six auction snapshots that genuinely gained the new instance entry are updated. Running the suite on a clean develop tree already rewrites ~300 unrelated snapshot files, so that pre-existing drift is left out of this PR.

Docs

  • stellar/MULTISIG.md — full rotation runbook (propose → approve → timelock → phase-guard check → execute → cancel), state inspection, and a compromised-key response note. Marked not yet rehearsed: the futurenet walkthrough and its run link still need a maintainer with deploy access, so that acceptance box stays open.
  • stellar/wraith-names/README.md — rotation surface, event shape, phase guard, and error list.
  • stellar/ERRORS.md — the two new codes.

Closes #165

The premium-name auction admin was fixed at init_auctions with no
rotation path, so a lost or compromised operator key had no on-chain
remedy short of the WASM upgrade entrypoint, which is not implemented.

Reuse the wraith-names governance signer set and the 7-day
ROTATION_TIMELOCK_SECS from the signer-rotation flow: propose → approve
to quorum → wait out the timelock → execute, under a separate proposal
slot so a signer rotation and an admin rotation can be in flight at
once. Execution emits AuctionAdminRotated(old_admin, new_admin).

Rotation is refused with AuctionInProgress while any auction has a
revealed winner and has not settled — its reveal phase and the settle
phase that follows — so the operator cannot be swapped mid-auction. A
new instance counter tracks those auctions; auctions nobody revealed a
bid on have nothing at stake and are not counted. A blocked execution
leaves the proposal intact, and settlement is permissionless, so
governance can always clear the guard and retry without restarting the
timelock.

Closes wraith-protocol#165
@drips-wave

drips-wave Bot commented Aug 28, 2026

Copy link
Copy Markdown

@DSOTec Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@truthixify
truthixify merged commit 8bc5b0e into wraith-protocol:develop Aug 28, 2026
13 checks passed
@truthixify

Copy link
Copy Markdown
Contributor

Merged, and this is the best contracts PR of the wave @DSOTec.

I told you on #165 that a global auction-liveness check was not implementable in Soroban because there is no key enumeration. You did not take that at face value, and the PendingSettlements counter answers the question in O(1) without enumerating anything. It is also strictly better than the init_auctions bound I proposed, because it catches auctions opened after the proposal but before execution, which my version would have missed entirely. Leaving the proposal intact on rejection so the timelock does not restart is the right detail too.

I have posted a correction on #165 so the wrong version does not stand. Ten rotation tests including the blocked-during-reveal and not-blocked-by-unrevealed cases is exactly the coverage this needed.

@DSOTec

DSOTec commented Aug 29, 2026

Copy link
Copy Markdown
Contributor Author

It's such a pleasure working on this issue. Looking forward to contributing more @truthixify

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.

Auction admin rotation via multisig + timelock

2 participants