Skip to content
Draft
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
146 changes: 146 additions & 0 deletions tips/tip-1048.md
Original file line number Diff line number Diff line change
@@ -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.
Loading