From 127cb2456c0c01aa2ee73bad24e8af59a293c053 Mon Sep 17 00:00:00 2001 From: Centaur AI Date: Fri, 1 May 2026 14:54:46 +0000 Subject: [PATCH] TIP-1048: Two-step ownership transfer for ValidatorConfig V2 Amp-Thread-ID: https://ampcode.com/threads/T-019de065-3223-72de-9f85-ed725ba48e86 Co-authored-by: Amp --- tips/tip-1048.md | 146 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 tips/tip-1048.md diff --git a/tips/tip-1048.md b/tips/tip-1048.md new file mode 100644 index 0000000000..120d6444a8 --- /dev/null +++ b/tips/tip-1048.md @@ -0,0 +1,146 @@ +--- +id: TIP-1048 +title: Two-step ownership transfer for ValidatorConfig V2 +description: Adds pending-owner acceptance to contract and validator ownership transfers to prevent typo-induced loss of control +authors: Howy (@howydev) +status: Draft +related: TIP-1017 +protocolVersion: T5 +--- + +# TIP-1048: Two-step ownership transfer for ValidatorConfig V2 + +## Abstract + +Replace the single-transaction `transferOwnership` and `transferValidatorOwnership` functions in ValidatorConfig V2 with a two-step propose-then-accept pattern. The current owner proposes a new owner, and the proposed address must send a second transaction to accept. This eliminates the risk of irrecoverable typos in ownership transfers. + +## Motivation + +Both `transferOwnership(newOwner)` and `transferValidatorOwnership(idx, newAddress)` take effect immediately. If the caller passes an incorrect address the transfer is irreversible: no entity controls the destination key, so contract ownership or validator control is permanently lost. A two-step flow (propose → accept) ensures the recipient proves key control before the transfer completes. + +This is an industry-standard pattern (OpenZeppelin `Ownable2Step`) and requires only new storage slots and four new functions in the existing precompile, activated behind a hardfork gate. + +## Assumptions + +- The precompile already stores `owner` and per-validator `validatorAddress`; adding `pendingOwner` and per-validator `pendingValidatorOwner` uses the same storage model. +- Only one pending transfer can be active at a time per ownership slot. A new proposal overwrites any prior pending value. +- Existing single-step transfer functions are disabled at the fork activation height. + +--- + +# Specification + +## New Storage + +| Slot | Type | Description | +|------|------|-------------| +| `pendingOwner` | `address` | Proposed next contract owner. Zero when no proposal is active. | +| `pendingValidatorOwner[idx]` | `address` | Proposed next address for validator at index `idx`. Zero when no proposal is active. | + +## New Functions + +```solidity +/// @notice Propose a new contract owner (owner only). +/// @dev Sets pendingOwner. Overwrites any prior pending proposal. Reverts if newOwner is zero. +/// @param newOwner Proposed owner address. +function proposeOwnership(address newOwner) external; + +/// @notice Accept contract ownership (pendingOwner only). +/// @dev Sets owner to msg.sender, clears pendingOwner. +function acceptOwnership() external; + +/// @notice Propose a new address for a validator entry (owner or current validator only). +/// @dev Sets pendingValidatorOwner[idx]. Overwrites any prior pending proposal for this index. Reverts if newAddress is zero or conflicts with an active validator. +/// @param idx Validator index. +/// @param newAddress Proposed new validator address. +function proposeValidatorOwnership(uint64 idx, address newAddress) external; + +/// @notice Accept validator ownership (pendingValidatorOwner[idx] only). +/// @dev Transfers the validator entry to msg.sender, clears pendingValidatorOwner[idx]. +/// @param idx Validator index. +function acceptValidatorOwnership(uint64 idx) external; +``` + +## New Events + +```solidity +event OwnershipProposed(address indexed currentOwner, address indexed pendingOwner); +event ValidatorOwnershipProposed(uint64 indexed index, address indexed currentAddress, address indexed pendingAddress, address caller); +``` + +Acceptance emits the existing `OwnershipTransferred` and `ValidatorOwnershipTransferred` events. + +## New Errors + +```solidity +/// @notice Caller is not the pending owner / pending validator owner. +error NotPendingOwner(); + +/// @notice Proposed address is the zero address. +error InvalidPendingOwner(); +``` + +## Behavior + +### `proposeOwnership(newOwner)` + +1. Require `msg.sender == owner`. +2. Require `newOwner != address(0)`. +3. Set `pendingOwner = newOwner`. +4. Emit `OwnershipProposed(owner, newOwner)`. + +### `acceptOwnership()` + +1. Require `msg.sender == pendingOwner`. +2. Set `owner = msg.sender`, `pendingOwner = address(0)`. +3. Emit `OwnershipTransferred(oldOwner, msg.sender)`. + +### `proposeValidatorOwnership(idx, newAddress)` + +1. Require validator at `idx` is active. +2. Require `msg.sender` is owner or current `validatorAddress`. +3. Require `newAddress != address(0)`. +4. Require `newAddress` does not conflict with an existing active validator address. +5. Set `pendingValidatorOwner[idx] = newAddress`. +6. Emit `ValidatorOwnershipProposed(idx, validatorAddress, newAddress, msg.sender)`. + +### `acceptValidatorOwnership(idx)` + +1. Require `msg.sender == pendingValidatorOwner[idx]`. +2. Require `newAddress` does not conflict with an existing active validator address (re-check at acceptance time). +3. Transfer: update `validatorAddress`, address-to-index mappings. +4. Clear `pendingValidatorOwner[idx]`. +5. Emit `ValidatorOwnershipTransferred(idx, oldAddress, msg.sender, msg.sender)`. + +### Cancellation + +A proposal is implicitly cancelled by: +- The proposer calling `proposeOwnership` / `proposeValidatorOwnership` with a different address. +- Deactivation of the validator (for validator ownership proposals). + +No explicit cancel function is required; the proposer can overwrite the pending value at any time. + +## Hardfork Gating + +- Before activation: `transferOwnership` and `transferValidatorOwnership` continue to work as today. The four new function selectors revert. +- After activation: `transferOwnership` and `transferValidatorOwnership` revert. Only the propose/accept pairs are available. + +--- + +# Invariants + +1. **Pending owner is never implicitly promoted**: `owner` and `validatorAddress` only change inside `acceptOwnership` / `acceptValidatorOwnership`, never on proposal alone. +2. **Acceptance requires exact sender match**: `acceptOwnership` reverts unless `msg.sender == pendingOwner`; `acceptValidatorOwnership` reverts unless `msg.sender == pendingValidatorOwner[idx]`. +3. **Address uniqueness preserved**: The active-validator unique-address invariant from TIP-1017 holds. Conflicts are checked at both proposal and acceptance time. +4. **Single pending value per slot**: A new proposal overwrites the previous one; at most one pending address exists per ownership slot at any time. +5. **Deactivation clears pending**: If a validator is deactivated, any `pendingValidatorOwner[idx]` is cleared. + +### Test coverage + +- Propose then accept for both contract and validator ownership succeeds. +- Accept from a non-pending address reverts with `NotPendingOwner`. +- Propose with `address(0)` reverts with `InvalidPendingOwner`. +- A second proposal overwrites the first; only the latest pending address can accept. +- Address conflict at acceptance time reverts even if proposal passed. +- `transferOwnership` and `transferValidatorOwnership` revert after fork activation. +- `proposeOwnership` and related functions revert before fork activation.