From bdbbc23b57a401ebd33edb8a3ae9748572ca21f3 Mon Sep 17 00:00:00 2001 From: howydev <132113803+howydev@users.noreply.github.com> Date: Wed, 25 Feb 2026 20:41:19 -0500 Subject: [PATCH 01/10] docs: simplify TIP-1017 abstract, motivation, and notation Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-019c975c-5086-76f9-8908-fde47394f412 --- tips/tip-1017.md | 579 +++++++++++------------------------------------ 1 file changed, 128 insertions(+), 451 deletions(-) diff --git a/tips/tip-1017.md b/tips/tip-1017.md index 1478cf74c4..803ae30d81 100644 --- a/tips/tip-1017.md +++ b/tips/tip-1017.md @@ -1,73 +1,24 @@ -# ValidatorConfig V2 - -This document specifies the second version of the Validator Config precompile, -introducing an append-only, delete-once design that eliminates the need for -historical state access during node recovery. - -- **TIP ID**: TIP-1017 -- **Authors/Owners**: Janis -- **Status**: Draft -- **Related Specs/TIPs**: N/A -- **Protocol Version**: TBD - +--- +id: TIP-1017 +title: Validator Config V2 precompile +description: Validator Config V2 precompile for improved management of consensus participants +authors: Janis (@superfluffy) +status: Draft --- -# Overview +# ValidatorConfig V2 ## Abstract -TIP-1017 introduces ValidatorConfig V2, a new precompile for managing consensus -validators with append-only semantics. Unlike the original ValidatorConfig, V2 -replaces the mutable `active` boolean with `addedAtHeight` (set when adding an -entry) and `deactivatedAtHeight` fields (set when deactivating), enabling nodes to -reconstruct the validator set for any historical epoch using only current state. -The new design also requires Ed25519 signature verification when adding -validators to prove key ownership. +TIP-1017 introduces ValidatorConfig V2, a new precompile for managing consensus participants. ValidatorConfigV2 introduces clearer tracking for accurately reconstructing validator sets for any epoch, and extra safety checks on validator input values. Validator config v2 is designed with security in mind that would allow for permissionless validator rotation. ## Motivation -The original ValidatorConfig precompile allows validators to be updated -arbitrarily, which creates challenges for node recovery: - -1. **Historical state dependency**: To determine the validator set at a past - epoch, nodes must access historical account state, which requires retaining - and indexing all historical data. - -2. **Key ownership verification**: V1 does not verify that the caller controls - the private key corresponding to the public key being registered, allowing - potential key squatting attacks. - -3. **Validator re-registration**: V1 allows deleted validators to be re-added - with different parameters, complicating historical queries. - -V2 solves these problems with an append-only design where: -- Validators are immutable after creation (no `updateValidator`) -- `addedAtHeight` and `deactivatedAtHeight` fields enable historical reconstruction - from current state -- Ed25519 signature verification proves key ownership at registration time -- Public keys remain reserved forever (even after deactivation); addresses are unique among current validators but can be reassigned via `transferValidatorOwnership` - -### Key Design Principle - -By recording `addedAtHeight` and `deactivatedAtHeight`, nodes can determine DKG players for any epoch using only current state. When preparing for a DKG ceremony in epoch `E+1`, a node reads the contract at `boundary(E)` and filters: - -``` -players(E+1) = validators.filter(v => - v.addedAtHeight <= boundary(E) && - (v.deactivatedAtHeight == 0 || v.deactivatedAtHeight > boundary(E)) -) -``` - -Both addition and deactivation take effect at the next epoch boundary—there is no warmup or cooldown period. - -This eliminates the need to retain historical account state for consensus recovery—nodes can derive DKG player sets from the current contract state alone. - ---- +V1 allowed the chain to switch validators between `active` and `inactive`, forcing nodes to retain historical account state for each epoch. Insufficient safety checks on precompile inputs also prevented validators from rotating permissionlessly, and this was permanently disabled on Tempo by setting the addresses of validators to unowned addresses. This TIP also introduces extra safety checks on validator inputs as an extra layer of security. # Specification ## Precompile Address - ```solidity address constant VALIDATOR_CONFIG_V2_ADDRESS = 0xCCCCCCCC00000000000000000000000000000001; ``` @@ -80,15 +31,6 @@ pragma solidity ^0.8.13; /// @title IValidatorConfigV2 - Validator Config V2 Precompile Interface /// @notice Interface for managing consensus validators with append-only, delete-once semantics -/// @dev This precompile manages the set of validators that participate in consensus. -/// V2 uses an append-only design that eliminates the need for historical state access -/// during node recovery. Validators are immutable after creation and can only be deleted once. -/// -/// Key differences from V1: -/// - `active` bool replaced by `addedAtHeight` and `deactivatedAtHeight` -/// - No `updateValidator` - validators are immutable after creation -/// - Requires Ed25519 signature on `addValidator` to prove key ownership -/// - Both address and public key must be unique across all validators (including deleted) interface IValidatorConfigV2 { /// @notice Thrown when caller lacks authorization to perform the requested action @@ -113,10 +55,9 @@ interface IValidatorConfigV2 { error InvalidSignature(); /// @notice Thrown when address is not in valid ip:port format - /// @param field The field name that failed validation /// @param input The invalid input that was provided /// @param backtrace Additional error context - error NotIpPort(string field, string input, string backtrace); + error NotIpPort(string input, string backtrace); /// @notice Thrown when trying to use an ingress IP already in use by another active validator /// @param ingress The ingress address that is already in use @@ -124,7 +65,7 @@ interface IValidatorConfigV2 { /// @notice Validator information (V2 - append-only, delete-once) /// @param publicKey Ed25519 communication public key (non-zero, unique across all validators) - /// @param validatorAddress Ethereum-style address of the validator (unique across all validators) + /// @param validatorAddress Ethereum-style address of the validator (unique among active validators) /// @param ingress Address where other validators can connect (format: `:`) /// @param egress IP address from which this validator will dial, e.g. for firewall whitelisting (format: ``) /// @param index Position in validators array (assigned at creation, immutable) @@ -140,6 +81,11 @@ interface IValidatorConfigV2 { uint64 deactivatedAtHeight; } + /// @notice Get all validators (including deleted ones) in array order + /// @return validators Array of all validators with their information + /// @dev Function is susceptible to state inflation attacks that can cause this to OOG + function getAllValidators() external view returns (Validator[] memory validators); + /// @notice Get only active validators (where deactivatedAtHeight == 0) /// @return validators Array of active validators function getActiveValidators() external view returns (Validator[] memory validators); @@ -179,7 +125,7 @@ interface IValidatorConfigV2 { /// @notice Add a new validator (owner only) /// @dev The signature must be an Ed25519 signature over: - /// keccak256(abi.encodePacked(bytes8(chainId), contractAddress, validatorAddress, ingress, egress)) + /// keccak256(bytes8(chainId) || contractAddress || validatorAddress || ingress || egress) /// using the namespace "TEMPO_VALIDATOR_CONFIG_V2_ADD_VALIDATOR". /// This proves the caller controls the private key corresponding to publicKey. /// Reverts if isInitialized() returns false. @@ -199,24 +145,14 @@ interface IValidatorConfigV2 { /// @notice Deactivates a validator (owner or existing validator) /// @dev Marks the validator as deactivated by setting deactivatedAtHeight to the current block height. /// The validator's entry remains in storage for historical queries. - /// The public key remains reserved and cannot be reused. The address remains - /// reserved unless reassigned via transferValidatorOwnership. + /// The public key remains reserved and cannot be reused. The address becomes + /// available for reuse by a new validator or via transferValidatorOwnership. /// /// @param validatorAddress The validator address to deactivate function deactivateValidator(address validatorAddress) external; /// @notice Rotate a validator to a new identity (owner or validator only) - /// @dev Atomically deletes the specified validator entry and adds a new one. This is equivalent - /// to calling deactivateValidator followed by addValidator, but executed atomically. - /// Can be called by the contract owner or by the validator's own address. - /// The same validation rules as addValidator apply: - /// - The new public key must not already exist - /// - Ingress parseable as :. - /// - Egress must be parseable as . - /// - The signature must prove ownership of the new public key - /// The signature must be an Ed25519 signature over: - /// keccak256(abi.encodePacked(bytes8(chainId), contractAddress, validatorAddress, ingress, egress)) - /// using the namespace "TEMPO_VALIDATOR_CONFIG_V2_ROTATE_VALIDATOR". + /// @dev Atomically deactivates the specified validator entry and appends a new one. The same validation rules for addValidator also applies to rotateValidator. /// @param validatorAddress The address of the validator to rotate /// @param publicKey The new validator's Ed25519 communication public key /// @param ingress The new validator's inbound address `:` for incoming connections @@ -260,26 +196,12 @@ interface IValidatorConfigV2 { /// Epoch N runs the ceremony, and epoch N+1 uses the new DKG polynomial. function setNextFullDkgCeremony(uint64 epoch) external; - /// @notice Migrate a single validator from V1 to V2 (owner only) - /// @dev Can be called multiple times to migrate validators one at a time. - /// On first call, copies owner from V1 if V2 owner is address(0). - /// Active V1 validators get addedAtHeight=block.height and deactivatedAtHeight=0. - /// Inactive V1 validators get addedAtHeight=block.height and deactivatedAtHeight=block.height at migration time. - /// Reverts if already initialized or already migrated. - /// Reverts if idx != validatorsArray.length. - /// Reverts if `V2.isInitialized()` (no migrations after V2 is initialized). + /// @notice Migrate a single validator at `idx` from V1 to V2 (owner only) /// @param idx Index of the validator in V1 validators array (must equal current validatorsArray.length) function migrateValidator(uint64 idx) external; /// @notice Initialize V2 and enable reads (owner only) /// @dev Should only be called after all validators have been migrated via migrateValidator. - /// Sets initialized=true and initializedAtHeight=block.height. After this call, - /// CL reads from V2 instead of V1. - /// Copies nextDkgCeremony from V1. - /// Reverts if V2 validators count < V1 validators count (ensures all validators migrated). - /// Reverts if validator activity does not match between contracts: - /// + if `V1.active == true` then `V2.deactivatedAtHeight = 0` - /// + if `V1.active == false` then `V2.deactivatedAtHeight > 0` function initializeIfMigrated() external; /// @notice Check if V2 has been initialized from V1 @@ -288,105 +210,48 @@ interface IValidatorConfigV2 { } ``` -## Behavior +## Changes From V1 ### Validator Lifecycle -Unlike V1, validators in V2 follow a strict lifecycle: - -1. **Addition**: `addValidator` creates an immutable validator entry with - `addedAtHeight` set to the current block height and `deactivatedAtHeight = 0` -2. **Active period**: Validator participates in consensus while - `deactivatedAtHeight == 0` -3. **Deactivation**: `deactivateValidator` sets `deactivatedAtHeight` to the current block - height -4. **Preserved**: The validator entry remains in storage forever for historical - queries - -``` -┌─────────────┐ addValidator() ┌─────────────┐ deactivateValidator() ┌─────────────┐ -│ │ ────────────────────► │ │ ────────────────────────► │ │ -│ Not Exist │ │ Active │ │ Deactivated │ -│ │ │ deactiv.=0 │ │ deactiv.>0 │ -└─────────────┘ └─────────────┘ └─────────────┘ - │ │ - │◄────────────────────────/───────────────┘ - │ (No transition back) -``` - -### Signature Verification - -When adding a validator, the caller must provide an Ed25519 signature proving ownership of the public key. The -signature is checked over the a full message containing: the length of the namespace in bytes, the namespace, and a -32-bytes hashed message. - -**Namespace:** `b"TEMPO_VALIDATOR_CONFIG_V2_ADD_VALIDATOR"` - -**Message:** -``` -message = keccak256(abi.encodePacked( - bytes8(chainId), // uint64: Prevents cross-chain replay - contractAddress, // address: Prevents cross-contract replay - validatorAddress, // address: Binds to specific validator address - ingress, // string: Binds network configuration - egress // string: Binds network configuration -)) -``` +Validators in V2 follow a fixed lifecycle: +1. **Addition**: Either from `addValidator` or `migrateValidator` from V1. This creates an immutable validator entry with `addedAtHeight` set to the current block height and `deactivatedAtHeight = 0`. +2. **Active period**: Validator participates in consensus while `deactivatedAtHeight == 0`. +3. **Deactivation**: Either `deactivateValidator` or `rotateValidator` sets `deactivatedAtHeight` for that validator entry to the current block height. -The Ed25519 signature is computed over the message using the namespace parameter -(see commonware's [signing scheme](https://github.com/commonwarexyz/monorepo/blob/abb883b4a8b42b362d4003b510bd644361eb3953/cryptography/src/ed25519/scheme.rs#L38-L40) -and [union format](https://github.com/commonwarexyz/monorepo/blob/abb883b4a8b42b362d4003b510bd644361eb3953/utils/src/lib.rs#L166-L174)). +Note that once `addedAtHeight` or `deactivatedAtHeight` for a validator entry is set, it cannot be set again any way else. This means that after deactivation, the validator entry stays in storage forever. -For validator rotations, the namespace `b"TEMPO_VALIDATOR_CONFIG_V2_ROTATE_VALIDATOR"` is used instead. +### New V2 Safety Checks -### Determining Active Validators +Validator config V2 introduces additional safety checks on validator inputs over validator config V1. +1. Validator ed25519 public keys must be unique across all validators (active + inactive). +2. Validator addresses must be unique across active validators. Duplication in the set of inactive validators is allowed. +3. The ingress (previously "inbound") must be a valid IP with port (:), and the IP must be unique across all active validators. +4. The egress (previously "outbound") must be a valid IP. +5. A signature must be provided over the parameters of `addValidator` or `rotateValidator` from the ed25519 key to be added. This is an anti-footgun mechanism to prevent accidentally adding the wrong key. The signature format is detailed below +6. Each validators maintains a constant `idx` value in the validator array. In V1, we used `validatorAddress` for update and delete operations, which allowed validators to front-run the call to prevent themselves from being updated or deleted by the owner. -Reading this contract alone is **not sufficient** to determine who the active validators (signers) are during a given epoch. The contract only records which validators are *eligible* to participate in DKG ceremonies—it does not record DKG outcomes. +### Ed25519 Signature Verification on Adding a New Validator -To determine the actual validators for epoch `E+1`: +When adding a validator, the caller must provide an Ed25519 signature proving ownership of the public key. The signature is checked over the a full message containing: the length of the namespace in bytes, the namespace, and a 32-bytes hashed message. -1. Read the DKG outcome from block `boundary(E)` -2. The DKG outcome contains the Ed25519 public keys of successful DKG players -3. Match these public keys against the contract via `validatorByPublicKey()` to obtain validator addresses and IP addresses +**Namespace:** `addValidator` uses `b"TEMPO_VALIDATOR_CONFIG_V2_ADD_VALIDATOR"` and `rotateValidator` uses `b"TEMPO_VALIDATOR_CONFIG_V2_ROTATE_VALIDATOR"`. +**Message:** ``` -activeValidators(E+1) = dkgOutcome(boundary(E)).players.map(pubkey => - contract.validatorByPublicKey(pubkey) +message = keccak256( + bytes8(chainId) // uint64: Prevents cross-chain replay + || contractAddress // address: Prevents cross-contract replay + || validatorAddress // address: Binds to specific validator address + || ingress // string: Binds network configuration + || egress // string: Binds network configuration ) ``` -This distinction matters because: -- The DKG can fail, reverting to the previous DKG outcome (for example, not all - eligible players may successfully participate in the DKG due to network - issues, crashes, etc.) -- The DKG outcome is the authoritative record of who holds valid key shares -- Only validators with valid shares can produce valid signatures in epoch `E+1` - -### Address Validation - -- **ingress**: Must be in `:` format. -- **egress**: Must be in `` format. - -Both IPv4 and IPv6 addresses are supported. For ingress, IPv6 addresses must be -enclosed in brackets: `[2001:db8::1]:8080`. - -### IP Address Uniqueness - -Only the ingress IP address must be unique among active validators: -- No two active validators can have the same ingress IP -- Egress addresses have no uniqueness constraint -- Deactivated validators excluded from checks, IP reuse is allowed after deactivation - -**Implementation**: Tracked via storage mapping (`active_ingress_ips: Mapping`) where keys are `keccak256(ingressIp)` for O(1) validation. - -**Enforcement**: -- `migrateValidator`: Rejects if ingress IP already in use by active validators -- `addValidator`: Rejects if ingress IP already in use -- `rotateValidator`: Rejects if new ingress IP already in use (after removing old) -- `setIpAddresses`: Rejects if new ingress IP already in use (after removing old) - -### Consensus Layer Behavior +The Ed25519 signature is computed over the message using the namespace parameter (see commonware's [signing scheme](https://github.com/commonwarexyz/monorepo/blob/abb883b4a8b42b362d4003b510bd644361eb3953/cryptography/src/ed25519/scheme.rs#L38-L40) and [union format](https://github.com/commonwarexyz/monorepo/blob/abb883b4a8b42b362d4003b510bd644361eb3953/utils/src/lib.rs#L166-L174)). +### TODO: Consensus Layer Usage + -```solidity -// V1 interface used during migration -interface IValidatorConfigV1 { - struct Validator { - bytes32 publicKey; - bool active; - uint64 index; - address validatorAddress; - string inboundAddress; - string outboundAddress; - } +## Migration - function getAllValidators() external view returns (Validator[] memory); - function getNextFullDkgCeremony() external view returns (uint64); - function owner() external view returns (address); -} -``` +If validator config v2 is to be used in a new network without v1, an additional `initialize` function is provided to skip migration. -### Properties +Validator migration must be carefully designed and executed to prevent footguns that could cause consensus to break down, triggering a chain halt. Due to the high gas costs of `SSTORE` imposed by TIP-1000, migration is performed manually and validator-by-validator to ensure that there is no risk of too many validators causing the migration transaction to fail from out-of-gas. -- **Per-validator migration**: Each validator is migrated with a separate tx -- **Owner controlled**: Only admin can migrate and initialize -- **Validator address copied**: The V2 validator address is copied from V1 -- **Address changeable post-migration**: Owner or validator can update validator addresses via `transferValidatorOwnership` -- **No signatures required**: V1 validators are imported without Ed25519 signatures - (they were already validated in V1) -- **All validators imported**: Both active and inactive V1 validators are - imported; active ones have `addedAtHeight == block.height` and `deactivatedAtHeight == 0`, - inactive ones have `addedAtHeight == deactivatedAtHeight == block.height`. -- **Initialize validates migration**: `initializeIfMigrated()` reverts if not - all V1 validators have been migrated +### Migration Process -## Timeline +1. At t2_hardfork timestamp or block_number, the validator config v2 becomes active on a new network. The consensus layer still continues to read from validator config v1 during this time. +2. The owner calls `migrateValidator(0)`. The first call of this copies the `owner` from validator config v1 over to validator config v2. +// TODO: Drop if fail check? +3. The owner continues and calls `migrateValidator(idx)` for `1...n` where `n` is the total number of validators in validator config v1. +4. During this migration, if a validator goes offline, the owner of the validator config contracts is able to deactivate the validator in both the v1 and v2 contracts. +5. When all validator entries have been copied over, the owner calls `initializeIfMigrated()` which performs checks that all validators have been migrated over and the `active` state between v1 and v2 matches. It also sets `initialized` to true which signals to the CL to start reading from validator config v2 instead of v1. +6. The owner collects addresses from each validator and calls `transferValidatorOwnership` for each entry to enable permissionless rotation. -``` -Before Fork Post-Fork (V2 not init) Admin Migration After initializeIfMigrated() - │ │ │ │ - │ CL reads V1 │ CL reads V1 │ migrateValidator() │ CL reads V2 - │ │ (isInitialized=false) │ (one tx per validator) │ isInitialized=true - │ │ │ │ -─────┴──────────────────────┴──────────────────────────┴──────────────────────────┴───────────────► - │ │ │ - hardforkTime migrateValidator() x N initializeIfMigrated() -``` - -## Migration Steps - -### For Existing Networks (testnet, mainnet) - -Existing networks are defined as those with hardfork_timestamp > timestamp_at_genesis +### Permitted Calls to V2 during migration -1. **Release new node software** with hardfork support -2. **Schedule the fork** by updating chainspec with target `hardforkTime` -3. **At fork activation**: CL reads from V1 (since `isInitialized() == false`) -4. **Admin migrates validators** by calling `migrateValidator(idx)` for each validator - - One transaction per validator - - Example: `migrateValidator(0)`, `migrateValidator(1)`, `migrateValidator(2)`, etc. - - Validator addresses are copied from V1 -5. **Admin calls `initializeIfMigrated()`** - - Sets `initialized = true` - - CL now reads from V2 -6. **Post-migration**: All reads/writes use V2 state directly +The owner is able to call: `deactivateValidator`, `transferOwnership`, `setIpAddresses`, `migrateValidator` and `initializeIfMigrated`. -**Important**: Complete migration before an epoch boundary to avoid disrupting DKG. +In theory, the validator is able to call `deactivateValidator` and `setIpAddresses`, but because the addresses are unowned, they are unable to do so. -### For New Networks +### Permitted Calls to V1 during migration -New networks are defined as those that have the V2 validator config contract at genesis. -In these cases, the V1 validator config contract is not required if validators are set up in V2 state. - -1. Call `initializeIfMigrated()` to set `initialized = true` and `initializeAtHeight = 0`. -2. Call `addValidator()` for each initial validator -3. Set ` = 0` to activate V2 immediately -4. V1 Validator Config contract/precompile is not necessary in this flow - ---- +All calls are allowed to V1 during migration (after t2_hardfork timestamp). Note that since validator addresses are unowned, validators are unable to call any function on V1. ## Test Cases - + # Security Issues - + From a86af6a2a47f1a39cbd6e9fcdd7eac3f217907eb Mon Sep 17 00:00:00 2001 From: howydev <132113803+howydev@users.noreply.github.com> Date: Wed, 25 Feb 2026 20:50:43 -0500 Subject: [PATCH 02/10] docs: fix TIP-1017 interface errors, grammar, and migration section Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-019c975c-5086-76f9-8908-fde47394f412 --- tips/tip-1017.md | 73 ++++++++++++++++++++++++++++++------------------ 1 file changed, 46 insertions(+), 27 deletions(-) diff --git a/tips/tip-1017.md b/tips/tip-1017.md index 803ae30d81..a4f438ba36 100644 --- a/tips/tip-1017.md +++ b/tips/tip-1017.md @@ -51,14 +51,34 @@ interface IValidatorConfigV2 { /// @notice Thrown when public key is invalid (zero) error InvalidPublicKey(); + /// @notice Thrown when validator address is invalid (zero) + error InvalidValidatorAddress(); + /// @notice Thrown when the Ed25519 signature verification fails error InvalidSignature(); + /// @notice Thrown when V2 is not yet initialized (writes blocked before init) + error NotInitialized(); + + /// @notice Thrown when V2 is already initialized (migration blocked after init) + error AlreadyInitialized(); + + /// @notice Thrown when migration is not complete (not all V1 validators migrated) + error MigrationNotComplete(); + + /// @notice Thrown when migration index is out of order + error InvalidMigrationIndex(); + /// @notice Thrown when address is not in valid ip:port format /// @param input The invalid input that was provided /// @param backtrace Additional error context error NotIpPort(string input, string backtrace); + /// @notice Thrown when address is not a valid IP (for egress field) + /// @param input The invalid input that was provided + /// @param backtrace Additional error context + error NotIp(string input, string backtrace); + /// @notice Thrown when trying to use an ingress IP already in use by another active validator /// @param ingress The ingress address that is already in use error IngressAlreadyExists(string ingress); @@ -81,20 +101,16 @@ interface IValidatorConfigV2 { uint64 deactivatedAtHeight; } - /// @notice Get all validators (including deleted ones) in array order - /// @return validators Array of all validators with their information - /// @dev Function is susceptible to state inflation attacks that can cause this to OOG - function getAllValidators() external view returns (Validator[] memory validators); + /// @notice Get all validators in array order starting from idx. + /// @dev The getter checks remaining gas before doing storage reads to ensure it will not fail from out-of-gas. + /// @param idx The index of validators to start reading from + /// @return validators Array of validators with their information + function getAllValidators(uint256 idx) external view returns (Validator[] memory validators); /// @notice Get only active validators (where deactivatedAtHeight == 0) /// @return validators Array of active validators function getActiveValidators() external view returns (Validator[] memory validators); - /// @notice Get the height at which the contract was initialized - /// @return the height at which the contract was initialized. Note that this - /// value only makes sense in conjunction with isInitialized() - function getInitializedAtHeight() external view returns (uint64); - /// @notice Get the owner of the precompile /// @return The owner address function owner() external view returns (address); @@ -142,12 +158,11 @@ interface IValidatorConfigV2 { bytes calldata signature ) external; - /// @notice Deactivates a validator (owner or existing validator) + /// @notice Deactivates a validator (owner or validator only) /// @dev Marks the validator as deactivated by setting deactivatedAtHeight to the current block height. /// The validator's entry remains in storage for historical queries. - /// The public key remains reserved and cannot be reused. The address becomes - /// available for reuse by a new validator or via transferValidatorOwnership. - /// + /// The public key remains reserved and cannot be reused. The address remains + /// reserved unless reassigned via transferValidatorOwnership. /// @param validatorAddress The validator address to deactivate function deactivateValidator(address validatorAddress) external; @@ -207,6 +222,11 @@ interface IValidatorConfigV2 { /// @notice Check if V2 has been initialized from V1 /// @return True if initialized, false otherwise function isInitialized() external view returns (bool); + + /// @notice Get the height at which the contract was initialized + /// @return the height at which the contract was initialized. Note that this + /// value only makes sense in conjunction with isInitialized() + function getInitializedAtHeight() external view returns (uint64); } ``` @@ -215,25 +235,25 @@ interface IValidatorConfigV2 { ### Validator Lifecycle Validators in V2 follow a fixed lifecycle: -1. **Addition**: Either from `addValidator` or `migrateValidator` from V1. This creates an immutable validator entry with `addedAtHeight` set to the current block height and `deactivatedAtHeight = 0`. +1. **Addition**: Either from `addValidator` or `migrateValidator` from V1. This creates a permanent validator entry with `addedAtHeight` set to the current block height and `deactivatedAtHeight = 0`. 2. **Active period**: Validator participates in consensus while `deactivatedAtHeight == 0`. 3. **Deactivation**: Either `deactivateValidator` or `rotateValidator` sets `deactivatedAtHeight` for that validator entry to the current block height. -Note that once `addedAtHeight` or `deactivatedAtHeight` for a validator entry is set, it cannot be set again any way else. This means that after deactivation, the validator entry stays in storage forever. +Note that once `addedAtHeight` or `deactivatedAtHeight` for a validator entry is set, it cannot be set again any way. After deactivation, the validator entry will stay in storage forever. ### New V2 Safety Checks -Validator config V2 introduces additional safety checks on validator inputs over validator config V1. +Validator config V2 introduces additional safety checks on validator inputs over validator config V1: 1. Validator ed25519 public keys must be unique across all validators (active + inactive). 2. Validator addresses must be unique across active validators. Duplication in the set of inactive validators is allowed. 3. The ingress (previously "inbound") must be a valid IP with port (:), and the IP must be unique across all active validators. 4. The egress (previously "outbound") must be a valid IP. 5. A signature must be provided over the parameters of `addValidator` or `rotateValidator` from the ed25519 key to be added. This is an anti-footgun mechanism to prevent accidentally adding the wrong key. The signature format is detailed below -6. Each validators maintains a constant `idx` value in the validator array. In V1, we used `validatorAddress` for update and delete operations, which allowed validators to front-run the call to prevent themselves from being updated or deleted by the owner. +6. Each validator maintains a constant `idx` value in the validator array. In V1, we used `validatorAddress` for update and delete operations, which allowed validators to front-run the call to prevent themselves from being updated or deleted by the owner. ### Ed25519 Signature Verification on Adding a New Validator -When adding a validator, the caller must provide an Ed25519 signature proving ownership of the public key. The signature is checked over the a full message containing: the length of the namespace in bytes, the namespace, and a 32-bytes hashed message. +When adding a validator, the caller must provide an Ed25519 signature proving ownership of the public key. The signature is checked over a full message containing: the length of the namespace in bytes, the namespace, and a 32-byte hashed message. **Namespace:** `addValidator` uses `b"TEMPO_VALIDATOR_CONFIG_V2_ADD_VALIDATOR"` and `rotateValidator` uses `b"TEMPO_VALIDATOR_CONFIG_V2_ROTATE_VALIDATOR"`. @@ -282,19 +302,18 @@ precompile does NOT proxy reads to V1. --> ## Migration -If validator config v2 is to be used in a new network without v1, an additional `initialize` function is provided to skip migration. +If validator config V2 is to be used in a new network without V1, `initializeIfMigrated` can be called immediately (with zero V1 validators) to skip migration. Validator migration must be carefully designed and executed to prevent footguns that could cause consensus to break down, triggering a chain halt. Due to the high gas costs of `SSTORE` imposed by TIP-1000, migration is performed manually and validator-by-validator to ensure that there is no risk of too many validators causing the migration transaction to fail from out-of-gas. ### Migration Process -1. At t2_hardfork timestamp or block_number, the validator config v2 becomes active on a new network. The consensus layer still continues to read from validator config v1 during this time. -2. The owner calls `migrateValidator(0)`. The first call of this copies the `owner` from validator config v1 over to validator config v2. -// TODO: Drop if fail check? -3. The owner continues and calls `migrateValidator(idx)` for `1...n` where `n` is the total number of validators in validator config v1. -4. During this migration, if a validator goes offline, the owner of the validator config contracts is able to deactivate the validator in both the v1 and v2 contracts. -5. When all validator entries have been copied over, the owner calls `initializeIfMigrated()` which performs checks that all validators have been migrated over and the `active` state between v1 and v2 matches. It also sets `initialized` to true which signals to the CL to start reading from validator config v2 instead of v1. -6. The owner collects addresses from each validator and calls `transferValidatorOwnership` for each entry to enable permissionless rotation. +1. At the hardfork timestamp or block number, validator config V2 becomes active on the network. The consensus layer still continues to read from validator config V1 during this time. +2. The owner calls `migrateValidator(0)`. The first call copies the `owner` from validator config V1 over to validator config V2. +3. The owner continues and calls `migrateValidator(idx)` for `1...n` where `n` is the total number of validators in validator config V1. +4. During this migration, if a validator goes offline, the owner is able to deactivate the validator in both the V1 and V2 contracts. +5. When all validator entries have been copied over, the owner calls `initializeIfMigrated()` which checks that all validators have been migrated over. It sets `initialized` to true which signals to the CL to start reading from validator config V2 instead of V1. +6. The owner collects addresses from each validator and calls `transferValidatorOwnership` for each entry to enable permissionless rotation. ### Permitted Calls to V2 during migration @@ -304,7 +323,7 @@ In theory, the validator is able to call `deactivateValidator` and `setIpAddress ### Permitted Calls to V1 during migration -All calls are allowed to V1 during migration (after t2_hardfork timestamp). Note that since validator addresses are unowned, validators are unable to call any function on V1. +All calls are allowed to V1 during migration (after the hardfork timestamp). Note that since validator addresses are unowned, validators are unable to call any function on V1. ## Test Cases - -# Security Issues - +1. **Append-only array**: `validatorsArray` length only increases; it never decreases. +2. **Immutable identity**: Once added, a validator's `publicKey`, `index`, and `addedAtHeight` never change. `ingress` and `egress` can be updated via `setIpAddresses`. `validatorAddress` can be changed via `transferValidatorOwnership`. +3. **Delete-once**: `deactivatedAtHeight` can only transition from 0 to a non-zero value, never back. +4. **Unique active addresses**: No two active validators share the same `validatorAddress`. Deactivated addresses may be reused. +5. **Unique public keys**: No two validators (including deactivated) share the same `publicKey`. +6. **Non-zero public keys**: All validators have a non-zero `publicKey`. +7. **Monotonic index**: Validator `index` equals its position in `validatorsArray`. +8. **Historical consistency**: For any height H, the active set is `{ v | v.addedAtHeight <= H && (v.deactivatedAtHeight == 0 || v.deactivatedAtHeight > H) }`. Validators with `addedAtHeight == deactivatedAtHeight` are never active. +9. **Signature binding**: The signature message includes `chainId`, `contractAddress`, `validatorAddress`, `ingress`, and `egress`, preventing cross-chain, cross-contract, and parameter replay. +10. **Initialized once**: The `initialized` flag can only transition from false to true, never back. +11. **Atomic rotation**: `rotateValidator` deactivates the old entry and appends a new entry in the same block height. From 5a4a9d370d1338c4b3300bda95abc89ecf731188 Mon Sep 17 00:00:00 2001 From: howydev <132113803+howydev@users.noreply.github.com> Date: Tue, 3 Mar 2026 02:14:10 -0500 Subject: [PATCH 04/10] wip update spec --- tips/tip-1017.md | 190 ++++++++++++++++++++++++----------------------- 1 file changed, 99 insertions(+), 91 deletions(-) diff --git a/tips/tip-1017.md b/tips/tip-1017.md index ff498b047f..49e7db0c65 100644 --- a/tips/tip-1017.md +++ b/tips/tip-1017.md @@ -2,7 +2,7 @@ id: TIP-1017 title: Validator Config V2 precompile description: Validator Config V2 precompile for improved management of consensus participants -authors: Janis (@superfluffy) +authors: Janis (@superfluffy), Howy (@howydev) status: Draft --- @@ -10,11 +10,11 @@ status: Draft ## Abstract -TIP-1017 introduces ValidatorConfig V2, a new precompile for managing consensus participants. ValidatorConfigV2 introduces clearer tracking for accurately reconstructing validator sets for any epoch, and extra safety checks on validator input values. Validator config v2 is designed with security in mind that would allow for permissionless validator rotation. +TIP-1017 defines ValidatorConfig V2, a new precompile for managing consensus participants. ValidatorConfig V2 improves validator lifecycle tracking so validator sets can be reconstructed accurately for any epoch, and adds stricter validation of validator inputs. The long-term goal is to safely support permissionless validator rotation. ## Motivation -V1 allowed the chain to switch validators between `active` and `inactive`, forcing nodes to retain historical account state for each epoch. Insufficient safety checks on precompile inputs also prevented validators from rotating permissionlessly, and this was permanently disabled on Tempo by setting the addresses of validators to unowned addresses. This TIP also introduces extra safety checks on validator inputs as an extra layer of security. +V1 allowed validators to switch between `active` and `inactive` states, forcing nodes to retain historical account state for each epoch. Insufficient safety checks on precompile inputs also prevented validators from rotating permissionlessly. Tempo's response was to permanently disable rotation by setting validator addresses to unowned addresses. This TIP also adds stricter validation of validator inputs to reduce operational risk. # Specification @@ -30,7 +30,7 @@ address constant VALIDATOR_CONFIG_V2_ADDRESS = 0xCCCCCCCC00000000000000000000000 pragma solidity ^0.8.13; /// @title IValidatorConfigV2 - Validator Config V2 Precompile Interface -/// @notice Interface for managing consensus validators with append-only, delete-once semantics +/// @notice Interface for managing consensus validators with append-only, deactivate-once semantics interface IValidatorConfigV2 { /// @notice Thrown when caller lacks authorization to perform the requested action @@ -45,8 +45,8 @@ interface IValidatorConfigV2 { /// @notice Thrown when validator is not found error ValidatorNotFound(); - /// @notice Thrown when trying to delete a validator that is already deleted - error ValidatorAlreadyDeleted(); + /// @notice Thrown when trying to deactivate a validator that is already deactivated + error ValidatorAlreadyDeactivated(); /// @notice Thrown when public key is invalid (zero) error InvalidPublicKey(); @@ -69,12 +69,12 @@ interface IValidatorConfigV2 { /// @notice Thrown when migration index is out of order error InvalidMigrationIndex(); - /// @notice Thrown when address is not in valid ip:port format + /// @notice Thrown when an address is not in a valid `IP:port` format /// @param input The invalid input that was provided /// @param backtrace Additional error context error NotIpPort(string input, string backtrace); - /// @notice Thrown when address is not a valid IP (for egress field) + /// @notice Thrown when an address is not a valid IP address (for `egress`) /// @param input The invalid input that was provided /// @param backtrace Additional error context error NotIp(string input, string backtrace); @@ -83,14 +83,14 @@ interface IValidatorConfigV2 { /// @param ingress The ingress address that is already in use error IngressAlreadyExists(string ingress); - /// @notice Validator information (V2 - append-only, delete-once) + /// @notice Validator information (V2 - append-only, deactivate-once) /// @param publicKey Ed25519 communication public key (non-zero, unique across all validators) /// @param validatorAddress Ethereum-style address of the validator (unique among active validators) /// @param ingress Address where other validators can connect (format: `:`) /// @param egress IP address from which this validator will dial, e.g. for firewall whitelisting (format: ``) /// @param index Position in validators array (assigned at creation, immutable) /// @param addedAtHeight Block height when validator was added - /// @param deactivatedAtHeight Block height when validator was deleted (0 = active) + /// @param deactivatedAtHeight Block height when validator was deactivated (0 = active) struct Validator { bytes32 publicKey; address validatorAddress; @@ -101,11 +101,11 @@ interface IValidatorConfigV2 { uint64 deactivatedAtHeight; } - /// @notice Get all validators in array order starting from idx. - /// @dev The getter checks remaining gas before doing storage reads to ensure it will not fail from out-of-gas. - /// @param idx The index of validators to start reading from + /// @notice Get validators in array order starting at `idx`. + /// @dev The getter checks remaining gas before storage reads and may return fewer entries than remain if gas is insufficient. + /// @param idx The index in the validators array to begin from /// @return validators Array of validators with their information - function getAllValidators(uint256 idx) external view returns (Validator[] memory validators); + function getValidators(uint64 idx) external view returns (Validator[] memory validators); /// @notice Get only active validators (where deactivatedAtHeight == 0) /// @return validators Array of active validators @@ -115,7 +115,7 @@ interface IValidatorConfigV2 { /// @return The owner address function owner() external view returns (address); - /// @notice Get total number of validators ever added (including deleted) + /// @notice Get the total number of validators ever added (including deactivated) /// @return The count of validators function validatorCount() external view returns (uint64); @@ -144,7 +144,7 @@ interface IValidatorConfigV2 { /// keccak256(bytes8(chainId) || contractAddress || validatorAddress || ingress || egress) /// using the namespace "TEMPO_VALIDATOR_CONFIG_V2_ADD_VALIDATOR". /// This proves the caller controls the private key corresponding to publicKey. - /// Reverts if isInitialized() returns false. + /// Reverts with `NotInitialized` if `isInitialized() == false`. /// @param validatorAddress The address of the new validator /// @param publicKey The validator's Ed25519 communication public key /// @param ingress The validator's inbound address `:` for incoming connections @@ -156,25 +156,25 @@ interface IValidatorConfigV2 { string calldata ingress, string calldata egress, bytes calldata signature - ) external; + ) external returns (uint64); /// @notice Deactivates a validator (owner or validator only) /// @dev Marks the validator as deactivated by setting deactivatedAtHeight to the current block height. /// The validator's entry remains in storage for historical queries. - /// The public key remains reserved and cannot be reused. The address remains - /// reserved unless reassigned via transferValidatorOwnership. - /// @param validatorAddress The validator address to deactivate - function deactivateValidator(address validatorAddress) external; - - /// @notice Rotate a validator to a new identity (owner or validator only) - /// @dev Atomically deactivates the specified validator entry and appends a new one. The same validation rules for addValidator also applies to rotateValidator. - /// @param validatorAddress The address of the validator to rotate + /// The public key remains reserved and cannot be reused. The address also remains + /// reserved unless reassigned via `transferValidatorOwnership`. + /// @param idx The global index into the validators array + function deactivateValidator(uint64 idx) external; + + /// @notice Atomically rotates a validator to a new identity (owner or validator only) + /// @dev To preserve index stability, rotation appends a deactivated snapshot and updates the original index with the new identity. + /// @param idx The global index of the validator to rotate /// @param publicKey The new validator's Ed25519 communication public key /// @param ingress The new validator's inbound address `:` for incoming connections /// @param egress The new validator's outbound IP address `` for firewall whitelisting /// @param signature Ed25519 signature (64 bytes) proving ownership of the new public key function rotateValidator( - address validatorAddress, + uint64 idx, bytes32 publicKey, string calldata ingress, string calldata egress, @@ -185,11 +185,11 @@ interface IValidatorConfigV2 { /// @dev Can be called by the contract owner or by the validator's own address. /// This allows validators to update their network addresses without requiring /// a full rotation. - /// @param validatorAddress The address of the validator to update + /// @param idx The global index of the validator to update /// @param ingress The new inbound address `:` for incoming connections /// @param egress The new outbound IP address `` for firewall whitelisting function setIpAddresses( - address validatorAddress, + uint64 idx, string calldata ingress, string calldata egress ) external; @@ -198,9 +198,9 @@ interface IValidatorConfigV2 { /// @dev Can be called by the contract owner or by the validator's own address. /// Updates the validator's address in the lookup maps. /// Reverts if the new address already exists in the validator set. - /// @param currentAddress The current address of the validator to transfer + /// @param idx The global index of the validator to transfer /// @param newAddress The new address to assign to the validator - function transferValidatorOwnership(address currentAddress, address newAddress) external; + function transferValidatorOwnership(uint64 idx, address newAddress) external; /// @notice Transfer owner of the contract (owner only) /// @param newOwner The new owner address @@ -212,11 +212,11 @@ interface IValidatorConfigV2 { function setNextFullDkgCeremony(uint64 epoch) external; /// @notice Migrate a single validator at `idx` from V1 to V2 (owner only) - /// @param idx Index of the validator in V1 validators array (must equal current validatorsArray.length) + /// @param idx Index of the validator in V1 validators array function migrateValidator(uint64 idx) external; /// @notice Initialize V2 and enable reads (owner only) - /// @dev Should only be called after all validators have been migrated via migrateValidator. + /// @dev Must only be called after all validators have been migrated via migrateValidator. function initializeIfMigrated() external; /// @notice Check if V2 has been initialized from V1 @@ -239,17 +239,17 @@ Validators in V2 follow a fixed lifecycle: 2. **Active period**: Validator participates in consensus while `deactivatedAtHeight == 0`. 3. **Deactivation**: Either `deactivateValidator` or `rotateValidator` sets `deactivatedAtHeight` for that validator entry to the current block height. -Note that once `addedAtHeight` or `deactivatedAtHeight` for a validator entry is set, it cannot be set again any way. After deactivation, the validator entry will stay in storage forever. +Once `addedAtHeight` or `deactivatedAtHeight` is set for a validator entry, it cannot be changed. After deactivation, the validator entry remains in storage forever. ### New V2 Safety Checks -Validator config V2 introduces additional safety checks on validator inputs over validator config V1: +ValidatorConfig V2 adds the following input safety checks over V1: 1. Validator ed25519 public keys must be unique across all validators (active + inactive). 2. Validator addresses must be unique across active validators. Duplication in the set of inactive validators is allowed. -3. The ingress (previously "inbound") must be a valid IP with port (:), and the IP must be unique across all active validators. -4. The egress (previously "outbound") must be a valid IP. -5. A signature must be provided over the parameters of `addValidator` or `rotateValidator` from the ed25519 key to be added. This is an anti-footgun mechanism to prevent accidentally adding the wrong key. The signature format is detailed below -6. Each validator maintains a constant `idx` value in the validator array. In V1, we used `validatorAddress` for update and delete operations, which allowed validators to front-run the call to prevent themselves from being updated or deleted by the owner. +3. The ingress ("inbound" in v1) must be a valid IP with port (:), and the IP must be unique across all active validators. +4. The egress ("outbound" in v1) must be a valid IP. +5. A signature from the Ed25519 key being added must be provided for `addValidator` and `rotateValidator`. This prevents accidental key misconfiguration. The signature scheme is defined below. +6. Each validator maintains a constant `idx` value in the validator array. ### Ed25519 Signature Verification on Adding a New Validator @@ -260,21 +260,23 @@ When adding a validator, the caller must provide an Ed25519 signature proving ow **Message:** ``` message = keccak256( - bytes8(chainId) // uint64: Prevents cross-chain replay - || contractAddress // address: Prevents cross-contract replay - || validatorAddress // address: Binds to specific validator address - || ingress // string: Binds network configuration - || egress // string: Binds network configuration + bytes8(chainId) // uint64: Prevents cross-chain replay + || contractAddress // address: Prevents cross-contract replay + || validatorAddress // address: Binds to specific validator address + || uint8(ingress.length) // uint8: Length of ingress + || ingress // string: Binds network configuration + || uint8(egress.length) // uint8: Length of egress + || egress // string: Binds network configuration ) ``` The Ed25519 signature is computed over the message using the namespace parameter (see commonware's [signing scheme](https://github.com/commonwarexyz/monorepo/blob/abb883b4a8b42b362d4003b510bd644361eb3953/cryptography/src/ed25519/scheme.rs#L38-L40) and [union format](https://github.com/commonwarexyz/monorepo/blob/abb883b4a8b42b362d4003b510bd644361eb3953/utils/src/lib.rs#L166-L174)). ### TODO: Consensus Layer Usage - ## Migration -If validator config V2 is to be used in a new network without V1, `initializeIfMigrated` can be called immediately (with zero V1 validators) to skip migration. +On networks that start directly with V2 (no V1 state), `initializeIfMigrated` MAY be called immediately when the V1 validator count is zero. -Validator migration must be carefully designed and executed to prevent footguns that could cause consensus to break down, triggering a chain halt. Due to the high gas costs of `SSTORE` imposed by TIP-1000, migration is performed manually and validator-by-validator to ensure that there is no risk of too many validators causing the migration transaction to fail from out-of-gas. +Validator migration must be carefully designed and executed to prevent footguns that could cause consensus to break down, triggering a chain halt. Due to the high gas costs of `SSTORE` imposed by TIP-1000, migration is performed manually and validator-by-validator to ensure that there is no risk of too many validators causing the migration transaction to fail from out-of-gas. ### Migration Process -1. At the hardfork timestamp or block number, validator config V2 becomes active on the network. The consensus layer still continues to read from validator config V1 during this time. -2. The owner calls `migrateValidator(0)`. The first call copies the `owner` from validator config V1 over to validator config V2. -3. The owner continues and calls `migrateValidator(idx)` for `1...n` where `n` is the total number of validators in validator config V1. -4. During this migration, if a validator goes offline, the owner is able to deactivate the validator in both the V1 and V2 contracts. -5. When all validator entries have been copied over, the owner calls `initializeIfMigrated()` which checks that all validators have been migrated over. It sets `initialized` to true which signals to the CL to start reading from validator config V2 instead of V1. -6. The owner collects addresses from each validator and calls `transferValidatorOwnership` for each entry to enable permissionless rotation. +1. At the hardfork timestamp or block number, ValidatorConfig V2 becomes available on the network, but the consensus layer continues reading ValidatorConfig V1 during migration. +2. The owner calls `migrateValidator(n-1)`, where `n` is the total number of validators in validator config V1. +3. That first call copies `owner` from V1 if the V2 owner is unset. The owner then migrates remaining indices in descending order (`n-2` down to `0`). +4. When all validator entries have been copied over, the owner calls `initializeIfMigrated()` which checks that all validators have been migrated over. It sets `initialized` to true which signals to the CL to start reading from validator config V2 instead of V1. +5. The owner collects addresses from each validator and calls `transferValidatorOwnership` for each entry to enable permissionless rotation. + +### Migration Edge Cases + +#### 1. A validator goes offline during the migration period + +During migration, the chain still reads V1. If a validator goes offline, the admin should deactivate it in V1 and if already migrated, also deactivate it in V2. + +#### 2. A validator entry in V1 is invalid -### Permitted Calls to V2 during migration +As checks move from the consensus layer into the precompile, migration must avoid introducing invalid values that could halt the chain. We do not need to guard against invalid IP addresses or duplicate public keys because this is impossible in ValidatorConfig V1. + +The strategy on encountering invalid validator entry values is to skip migrating that specific index. This happens when ed25519 public keys are invalid or if duplicate ingress IPs exist. For the case of duplicated ed25519 public keys, the implementation must overwrite the validator slot. When an overwrite or a skip happens, we should track that this validator index was processed as not to block migration. + +### Permitted Calls to V2 during Migration The owner is able to call: `deactivateValidator`, `transferOwnership`, `setIpAddresses`, `migrateValidator` and `initializeIfMigrated`. `migrateValidator` and `initializeIfMigrated` cannot be called once the contract is initialized. @@ -331,51 +344,46 @@ All calls are allowed to V1 during migration (after the hardfork timestamp). Not - **Migration timing**: Migration must be completed (including `initializeIfMigrated()`) before an epoch boundary to avoid disrupting DKG. The admin should schedule migration during a period with sufficient time buffer before the next epoch transition. -## Test Cases - -The test suite should cover the following categories: +- **Checking edge cases**: Because ValidatorConfig V1 is admin-controlled, the admin should run a pre-migration validation script to detect values that would fail V2 checks. -### Happy Paths +- **Differences in validator state in V1 and V2**: Admins must take precautions to ensure that state between v1 and v2 are consistent before calling `initialized`. This is best done with the same foundry script as above. -Each mutating function (`addValidator`, `deactivateValidator`, `rotateValidator`, `setIpAddresses`, `transferValidatorOwnership`, `transferOwnership`, `setNextFullDkgCeremony`, `migrateValidator`, `initializeIfMigrated`) succeeds when called by an authorized caller with valid inputs. +- **Signature domain separation**: `addValidator` and `rotateValidator` signatures must be bound to chain ID, precompile address, function namespace, validator address, and endpoint payload to prevent cross-chain, cross-contract, or cross-function replay. -### Authorization +## Race conditions for state-changing calls -- **Owner-only**: `addValidator`, `transferOwnership`, `migrateValidator`, `initializeIfMigrated`, `setNextFullDkgCeremony` revert with `Unauthorized` for non-owner callers. -- **Owner or validator**: `rotateValidator`, `deactivateValidator`, `setIpAddresses`, `transferValidatorOwnership` revert with `Unauthorized` for callers that are neither the owner nor the validator. -- **Pre-initialization**: `addValidator`, `rotateValidator`, `transferValidatorOwnership` revert with `NotInitialized` before `initializeIfMigrated()` is called. `deactivateValidator` and `setIpAddresses` are allowed before initialization. +The implementation should keep validator indices stable to prevent race conditions across state-changing operations. -### Uniqueness and Validation +## Griefing attacks -- **Public key uniqueness**: `addValidator` and `rotateValidator` revert with `PublicKeyAlreadyExists` if the key is already used by any validator (active or inactive). -- **Address uniqueness**: `addValidator` reverts with `AddressAlreadyHasValidator` if the address belongs to an active validator. Reuse of deactivated addresses is allowed. -- **Ingress IP uniqueness**: `addValidator`, `rotateValidator`, and `setIpAddresses` revert with `IngressAlreadyExists` if the ingress IP is already in use by another active validator (even with a different port). -- **Input validation**: Zero public keys revert with `InvalidPublicKey`. Zero addresses revert with `InvalidValidatorAddress`. Invalid ingress format reverts with `NotIpPort`. Invalid egress format reverts with `NotIp`. -- **Signature verification**: `addValidator` and `rotateValidator` revert with `InvalidSignature` for wrong signatures, wrong length, or wrong signer. +Because `rotateValidator` is permissionless and state is append-only, array getters should be designed to remain safe under unbounded growth from repeated rotations. -### State Transitions - -- **Deactivation is permanent**: `deactivateValidator` on an already-deactivated validator reverts with `ValidatorAlreadyDeleted`. Once `deactivatedAtHeight` is set, it cannot change. -- **Rotation is atomic**: `rotateValidator` deactivates the old entry and appends a new entry in the same transaction. The new entry gets the next available index. -- **Non-existent validators**: Operations on non-existent validators revert with `ValidatorNotFound`. - -### Migration +## Test Cases -- **Sequential migration**: `migrateValidator(idx)` reverts with `InvalidMigrationIndex` if `idx != validatorsArray.length`. -- **Migration blocked after init**: `migrateValidator` reverts with `AlreadyInitialized` after `initializeIfMigrated()`. -- **Init blocked if incomplete**: `initializeIfMigrated()` reverts with `MigrationNotComplete` if not all V1 validators have been migrated. -- **State copied correctly**: Migrated validators preserve public key, address, ingress, and egress from V1. Active V1 validators get `deactivatedAtHeight == 0`; inactive ones get `addedAtHeight == deactivatedAtHeight`. Owner and `nextDkgCeremony` are copied from V1. +There should be unit tests covering all possible branches in added functions. ## Invariants -1. **Append-only array**: `validatorsArray` length only increases; it never decreases. -2. **Immutable identity**: Once added, a validator's `publicKey`, `index`, and `addedAtHeight` never change. `ingress` and `egress` can be updated via `setIpAddresses`. `validatorAddress` can be changed via `transferValidatorOwnership`. -3. **Delete-once**: `deactivatedAtHeight` can only transition from 0 to a non-zero value, never back. -4. **Unique active addresses**: No two active validators share the same `validatorAddress`. Deactivated addresses may be reused. -5. **Unique public keys**: No two validators (including deactivated) share the same `publicKey`. -6. **Non-zero public keys**: All validators have a non-zero `publicKey`. -7. **Monotonic index**: Validator `index` equals its position in `validatorsArray`. -8. **Historical consistency**: For any height H, the active set is `{ v | v.addedAtHeight <= H && (v.deactivatedAtHeight == 0 || v.deactivatedAtHeight > H) }`. Validators with `addedAtHeight == deactivatedAtHeight` are never active. -9. **Signature binding**: The signature message includes `chainId`, `contractAddress`, `validatorAddress`, `ingress`, and `egress`, preventing cross-chain, cross-contract, and parameter replay. -10. **Initialized once**: The `initialized` flag can only transition from false to true, never back. -11. **Atomic rotation**: `rotateValidator` deactivates the old entry and appends a new entry in the same block height. +1. **Unique active addresses**: No two active validators share the same `validatorAddress`. Deactivated addresses may be reused. +2. **Unique public keys**: No two validators (including deactivated) share the same `publicKey`. +3. **Append-only validator array**: `validatorsArray` length can only increase. +4. **Entry index immutability**: Once a validator entry is created at index `i`, that entry can never move to another index. A previously deactivated operator may later be re-added as a new entry at a different index. +5. **Deactivate-once**: `deactivatedAtHeight` can only transition from 0 to a non-zero value, never back. +6. **Deactivated validator immutability**: Once `deactivatedAtHeight` is non-zero, that validator entry is immutable and can never become active again. +7. **Valid public keys**: All validators must have valid ed25519 `publicKey`s. +8. **Consistent validator index**: Validator `index` equals its position in `validatorsArray`. +9. **Safe pagination behavior**: `getValidators(startIndex)` may return a partial page due to gas limits, but must not revert solely because the remaining set is large. +10. **Full-set reconstruction by pagination**: Repeated `getValidators(startIndex)` calls (advancing `startIndex` by the number of entries returned each time) must reconstruct exactly the ordered set `validatorByIndex(0..validatorCount-1)`. +11. **Validator activity consistency**: Filtering the fully reconstructed validator set by `deactivatedAtHeight == 0` must produce exactly `getActiveValidators()` (same members, same order). +12. **Address round-trip for active entries**: For any `i` where `validatorByIndex(i).deactivatedAtHeight == 0`, `validatorByAddress(validatorByIndex(i).validatorAddress).index == i`. +13. **Public-key round-trip for all entries**: For any `i < validatorCount()`, `validatorByPublicKey(validatorByIndex(i).publicKey).index == i`. +14. **Ingress uniqueness across active validators**: In `getActiveValidators()`, no two validators share the same ingress IP component. +15. **Rotation preserves active cardinality**: A successful `rotateValidator` call does not change `getActiveValidators().length`. +16. **Deactivation decrements active cardinality by one**: A successful `deactivateValidator` call decreases `getActiveValidators().length` by exactly one. +17. **Rotate appends exactly one entry**: A successful `rotateValidator` call increases `validatorCount()` by exactly one. +18. **Initialization phase gating**: Before initialization, post-init mutators are blocked; after initialization, migration mutators are blocked. +19. **Initialized once**: The `initialized` flag can only transition from `false` to `true`, never back. +20. **Migration completion gate**: Each V1 index must be processed exactly once (migrated or skipped), and `initializeIfMigrated()` stays blocked until all indices are processed. +21. **Skipped-index counter monotonicity**: `migrationSkippedCount` is monotonically non-decreasing and may only change during `migrateValidator`. +22. **DKG continuity at initialization**: On successful `initializeIfMigrated`, `getNextFullDkgCeremony()` in V2 equals the value read from V1 at that moment. +23. **Owner bootstrap during migration**: If V2 owner is unset on first migration call, owner is copied from V1 exactly once and then used for all migration authorization checks. From 8fcca3b561aa629a766bb60c1e0dc2671a52663b Mon Sep 17 00:00:00 2001 From: howydev <132113803+howydev@users.noreply.github.com> Date: Tue, 3 Mar 2026 02:44:05 -0500 Subject: [PATCH 05/10] feat: improve flow --- tips/tip-1017.md | 409 ++++++++++++++++++++++++----------------------- 1 file changed, 212 insertions(+), 197 deletions(-) diff --git a/tips/tip-1017.md b/tips/tip-1017.md index 49e7db0c65..65d860798c 100644 --- a/tips/tip-1017.md +++ b/tips/tip-1017.md @@ -10,11 +10,11 @@ status: Draft ## Abstract -TIP-1017 defines ValidatorConfig V2, a new precompile for managing consensus participants. ValidatorConfig V2 improves validator lifecycle tracking so validator sets can be reconstructed accurately for any epoch, and adds stricter validation of validator inputs. The long-term goal is to safely support permissionless validator rotation. +TIP-1017 defines ValidatorConfig V2, a new precompile for managing consensus participants. V2 improves lifecycle tracking so validator sets can be reconstructed for any epoch, and adds stricter input validation. It also aims to safely support permissionless validator rotation. ## Motivation -V1 allowed validators to switch between `active` and `inactive` states, forcing nodes to retain historical account state for each epoch. Insufficient safety checks on precompile inputs also prevented validators from rotating permissionlessly. Tempo's response was to permanently disable rotation by setting validator addresses to unowned addresses. This TIP also adds stricter validation of validator inputs to reduce operational risk. +V1 allowed validators to switch between `active` and `inactive`, which forced nodes to retain historical account state for each epoch. Limited input safety checks also prevented safe permissionless rotation. Tempo addressed this by disabling rotation and setting validator addresses to unowned addresses. This TIP adds stricter input validation to reduce operational risk. # Specification @@ -33,64 +33,64 @@ pragma solidity ^0.8.13; /// @notice Interface for managing consensus validators with append-only, deactivate-once semantics interface IValidatorConfigV2 { - /// @notice Thrown when caller lacks authorization to perform the requested action + /// @notice Caller is not authorized. error Unauthorized(); - /// @notice Thrown when trying to add a validator with an address that already exists + /// @notice Active validator address already exists. error AddressAlreadyHasValidator(); - /// @notice Thrown when trying to add a validator with a public key that already exists + /// @notice Public key already exists. error PublicKeyAlreadyExists(); - /// @notice Thrown when validator is not found + /// @notice Validator was not found. error ValidatorNotFound(); - /// @notice Thrown when trying to deactivate a validator that is already deactivated + /// @notice Validator is already deactivated. error ValidatorAlreadyDeactivated(); - /// @notice Thrown when public key is invalid (zero) + /// @notice Public key is invalid. error InvalidPublicKey(); - /// @notice Thrown when validator address is invalid (zero) + /// @notice Validator address is invalid. error InvalidValidatorAddress(); - /// @notice Thrown when the Ed25519 signature verification fails + /// @notice Ed25519 signature verification failed. error InvalidSignature(); - /// @notice Thrown when V2 is not yet initialized (writes blocked before init) + /// @notice Contract is not initialized. error NotInitialized(); - /// @notice Thrown when V2 is already initialized (migration blocked after init) + /// @notice Contract is already initialized. error AlreadyInitialized(); - /// @notice Thrown when migration is not complete (not all V1 validators migrated) + /// @notice Migration is not complete. error MigrationNotComplete(); - /// @notice Thrown when migration index is out of order + /// @notice Migration index is out of order. error InvalidMigrationIndex(); - /// @notice Thrown when an address is not in a valid `IP:port` format - /// @param input The invalid input that was provided - /// @param backtrace Additional error context + /// @notice Address is not in valid `IP:port` format. + /// @param input Invalid input. + /// @param backtrace Additional error context. error NotIpPort(string input, string backtrace); - /// @notice Thrown when an address is not a valid IP address (for `egress`) - /// @param input The invalid input that was provided - /// @param backtrace Additional error context + /// @notice Address is not a valid IP address. + /// @param input Invalid input. + /// @param backtrace Additional error context. error NotIp(string input, string backtrace); - /// @notice Thrown when trying to use an ingress IP already in use by another active validator - /// @param ingress The ingress address that is already in use + /// @notice Ingress IP is already in use by an active validator. + /// @param ingress Conflicting ingress address. error IngressAlreadyExists(string ingress); - /// @notice Validator information (V2 - append-only, deactivate-once) - /// @param publicKey Ed25519 communication public key (non-zero, unique across all validators) - /// @param validatorAddress Ethereum-style address of the validator (unique among active validators) - /// @param ingress Address where other validators can connect (format: `:`) - /// @param egress IP address from which this validator will dial, e.g. for firewall whitelisting (format: ``) - /// @param index Position in validators array (assigned at creation, immutable) - /// @param addedAtHeight Block height when validator was added - /// @param deactivatedAtHeight Block height when validator was deactivated (0 = active) + /// @notice Validator information + /// @param publicKey Ed25519 communication public key. + /// @param validatorAddress Validator address. + /// @param ingress Inbound address in `:` format. + /// @param egress Outbound address in `` format. + /// @param index Immutable validators-array position. + /// @param addedAtHeight Block height when entry was added. + /// @param deactivatedAtHeight Block height when entry was deactivated (`0` if active). struct Validator { bytes32 publicKey; address validatorAddress; @@ -102,54 +102,49 @@ interface IValidatorConfigV2 { } /// @notice Get validators in array order starting at `idx`. - /// @dev The getter checks remaining gas before storage reads and may return fewer entries than remain if gas is insufficient. - /// @param idx The index in the validators array to begin from - /// @return validators Array of validators with their information + /// @dev May return a partial page when remaining gas is insufficient. + /// @param idx Start index in validators array. + /// @return validators Validators from `idx` onward. function getValidators(uint64 idx) external view returns (Validator[] memory validators); - /// @notice Get only active validators (where deactivatedAtHeight == 0) - /// @return validators Array of active validators + /// @notice Get active validators. + /// @return validators Active validators (`deactivatedAtHeight == 0`). function getActiveValidators() external view returns (Validator[] memory validators); - /// @notice Get the owner of the precompile - /// @return The owner address + /// @notice Get contract owner. + /// @return Owner address. function owner() external view returns (address); - /// @notice Get the total number of validators ever added (including deactivated) - /// @return The count of validators + /// @notice Get total validators, including deactivated entries. + /// @return count Validator count. function validatorCount() external view returns (uint64); - /// @notice Get validator information by index in the validators array - /// @param index The index in the validators array - /// @return The validator struct at the given index + /// @notice Get validator by array index. + /// @param index Validators-array index. + /// @return validator Validator at `index`. function validatorByIndex(uint64 index) external view returns (Validator memory); - /// @notice Get validator information by address - /// @param validatorAddress The validator address to look up - /// @return The validator struct for the given address + /// @notice Get validator by address. + /// @param validatorAddress Validator address. + /// @return validator Validator for `validatorAddress`. function validatorByAddress(address validatorAddress) external view returns (Validator memory); - /// @notice Get validator information by public key - /// @param publicKey The validator's public key to look up - /// @return The validator struct for the given public key + /// @notice Get validator by public key. + /// @param publicKey Ed25519 public key. + /// @return validator Validator for `publicKey`. function validatorByPublicKey(bytes32 publicKey) external view returns (Validator memory); - /// @notice Get the epoch at which a fresh DKG ceremony will be triggered - /// @return The epoch number, or 0 if no fresh DKG is scheduled. - /// The fresh DKG ceremony runs in epoch N, and epoch N+1 uses the new DKG polynomial. + /// @notice Get next epoch configured for a fresh DKG ceremony. + /// @return epoch Epoch number, or `0` if none is scheduled. function getNextFullDkgCeremony() external view returns (uint64); /// @notice Add a new validator (owner only) - /// @dev The signature must be an Ed25519 signature over: - /// keccak256(bytes8(chainId) || contractAddress || validatorAddress || ingress || egress) - /// using the namespace "TEMPO_VALIDATOR_CONFIG_V2_ADD_VALIDATOR". - /// This proves the caller controls the private key corresponding to publicKey. - /// Reverts with `NotInitialized` if `isInitialized() == false`. - /// @param validatorAddress The address of the new validator - /// @param publicKey The validator's Ed25519 communication public key - /// @param ingress The validator's inbound address `:` for incoming connections - /// @param egress The validator's outbound IP address `` for firewall whitelisting - /// @param signature Ed25519 signature (64 bytes) proving ownership of the public key + /// @dev Requires Ed25519 signature over a unique digest generated from inputs. + /// @param validatorAddress New validator address. + /// @param publicKey Validator Ed25519 communication public key. + /// @param ingress Inbound address `:`. + /// @param egress Outbound address ``. + /// @param signature Ed25519 signature proving key ownership. function addValidator( address validatorAddress, bytes32 publicKey, @@ -158,21 +153,18 @@ interface IValidatorConfigV2 { bytes calldata signature ) external returns (uint64); - /// @notice Deactivates a validator (owner or validator only) - /// @dev Marks the validator as deactivated by setting deactivatedAtHeight to the current block height. - /// The validator's entry remains in storage for historical queries. - /// The public key remains reserved and cannot be reused. The address also remains - /// reserved unless reassigned via `transferValidatorOwnership`. - /// @param idx The global index into the validators array + /// @notice Deactivate a validator (owner or validator only). + /// @dev Sets `deactivatedAtHeight` to current block height. + /// @param idx Validator index. function deactivateValidator(uint64 idx) external; - /// @notice Atomically rotates a validator to a new identity (owner or validator only) - /// @dev To preserve index stability, rotation appends a deactivated snapshot and updates the original index with the new identity. - /// @param idx The global index of the validator to rotate - /// @param publicKey The new validator's Ed25519 communication public key - /// @param ingress The new validator's inbound address `:` for incoming connections - /// @param egress The new validator's outbound IP address `` for firewall whitelisting - /// @param signature Ed25519 signature (64 bytes) proving ownership of the new public key + /// @notice Rotate a validator to a new identity (owner or validator only). + /// @dev Preserves index stability by appending a copy of the existing entry and updating the entry in-place. + /// @param idx Validator index to rotate. + /// @param publicKey New Ed25519 communication public key. + /// @param ingress New inbound address `:`. + /// @param egress New outbound address ``. + /// @param signature Ed25519 signature proving new key ownership. function rotateValidator( uint64 idx, bytes32 publicKey, @@ -181,83 +173,104 @@ interface IValidatorConfigV2 { bytes calldata signature ) external; - /// @notice Update a validator's IP addresses (owner or validator only) - /// @dev Can be called by the contract owner or by the validator's own address. - /// This allows validators to update their network addresses without requiring - /// a full rotation. - /// @param idx The global index of the validator to update - /// @param ingress The new inbound address `:` for incoming connections - /// @param egress The new outbound IP address `` for firewall whitelisting + /// @notice Update validator IP addresses (owner or validator only). + /// @param idx Validator index. + /// @param ingress New inbound address `:`. + /// @param egress New outbound address ``. function setIpAddresses( uint64 idx, string calldata ingress, string calldata egress ) external; - /// @notice Transfer a validator entry to a new address (owner or validator only) - /// @dev Can be called by the contract owner or by the validator's own address. - /// Updates the validator's address in the lookup maps. - /// Reverts if the new address already exists in the validator set. - /// @param idx The global index of the validator to transfer - /// @param newAddress The new address to assign to the validator + /// @notice Transfer validator entry to a new address (owner or validator only). + /// @dev Reverts if `newAddress` conflicts with an active validator. + /// @param idx Validator index. + /// @param newAddress New validator address. function transferValidatorOwnership(uint64 idx, address newAddress) external; - /// @notice Transfer owner of the contract (owner only) - /// @param newOwner The new owner address + /// @notice Transfer contract ownership (owner only). + /// @param newOwner New owner address. function transferOwnership(address newOwner) external; - /// @notice Set the epoch at which a fresh DKG ceremony will be triggered (owner only) - /// @param epoch The epoch in which to run the fresh DKG ceremony. - /// Epoch N runs the ceremony, and epoch N+1 uses the new DKG polynomial. + /// @notice Set next fresh DKG ceremony epoch (owner only). + /// @param epoch Epoch where ceremony runs (`epoch + 1` uses new polynomial). function setNextFullDkgCeremony(uint64 epoch) external; - /// @notice Migrate a single validator at `idx` from V1 to V2 (owner only) - /// @param idx Index of the validator in V1 validators array + /// @notice Migrate one validator by V1 index (owner only). + /// @param idx V1 validator index. function migrateValidator(uint64 idx) external; - /// @notice Initialize V2 and enable reads (owner only) - /// @dev Must only be called after all validators have been migrated via migrateValidator. + /// @notice Initialize V2 and enable reads (owner only). + /// @dev Requires all V1 indices to be processed. function initializeIfMigrated() external; - /// @notice Check if V2 has been initialized from V1 - /// @return True if initialized, false otherwise + /// @notice Check initialization state. + /// @return initialized True if initialized. function isInitialized() external view returns (bool); - /// @notice Get the height at which the contract was initialized - /// @return the height at which the contract was initialized. Note that this - /// value only makes sense in conjunction with isInitialized() + /// @notice Get initialization block height. + /// @return height Initialization height (`0` if not initialized). function getInitializedAtHeight() external view returns (uint64); } ``` -## Changes From V1 +## Overview -### Validator Lifecycle +- Migration incrementally reads and copies validator entries from V1 into V2. +- During migration, the consensus layer continues reading V1 until `initializeIfMigrated()` completes. +- Validator entries are append-only and follow deactivate-once semantics. +- Historical validator sets are reconstructed from `addedAtHeight` and `deactivatedAtHeight`. +- Validator `index` is stable for the lifetime of an entry. +- Writes for post-migration operations are gated by `isInitialized()`. -Validators in V2 follow a fixed lifecycle: -1. **Addition**: Either from `addValidator` or `migrateValidator` from V1. This creates a permanent validator entry with `addedAtHeight` set to the current block height and `deactivatedAtHeight = 0`. -2. **Active period**: Validator participates in consensus while `deactivatedAtHeight == 0`. -3. **Deactivation**: Either `deactivateValidator` or `rotateValidator` sets `deactivatedAtHeight` for that validator entry to the current block height. +## State Model -Once `addedAtHeight` or `deactivatedAtHeight` is set for a validator entry, it cannot be changed. After deactivation, the validator entry remains in storage forever. +V2 stores validators in one append-only array, with lookup indexes by address and public key. -### New V2 Safety Checks +- `addedAtHeight`: block height where the entry becomes visible to CL epoch filtering. +- `deactivatedAtHeight`: `0` means active; non-zero marks irreversible deactivation. +- `index`: immutable array position assigned at creation. +- `initialized`: one-way migration flag toggled by `initializeIfMigrated()`. -ValidatorConfig V2 adds the following input safety checks over V1: -1. Validator ed25519 public keys must be unique across all validators (active + inactive). -2. Validator addresses must be unique across active validators. Duplication in the set of inactive validators is allowed. -3. The ingress ("inbound" in v1) must be a valid IP with port (:), and the IP must be unique across all active validators. -4. The egress ("outbound" in v1) must be a valid IP. -5. A signature from the Ed25519 key being added must be provided for `addValidator` and `rotateValidator`. This prevents accidental key misconfiguration. The signature scheme is defined below. -6. Each validator maintains a constant `idx` value in the validator array. +## Operation Semantics -### Ed25519 Signature Verification on Adding a New Validator +### Lifecycle Operations -When adding a validator, the caller must provide an Ed25519 signature proving ownership of the public key. The signature is checked over a full message containing: the length of the namespace in bytes, the namespace, and a 32-byte hashed message. +- `addValidator`: appends a new active entry after validation and signature verification. +- `deactivateValidator`: marks an existing active entry as deactivated at current height. +- `rotateValidator`: deactivates the current active entry and appends its replacement, keeping the active validator count unchanged. + +### Network And Ownership Operations + +- `setIpAddresses`: updates ingress and egress for an active validator, enforcing address format and ingress uniqueness among active entries. +- `transferValidatorOwnership`: rebinds a validator entry to a new address provided the address is not used by another active entry. + +### Migration And Phase-Gating Operations + +- `migrateValidator`: copies one V1 entry into V2 in descending index order. +- `initializeIfMigrated`: switches V2 to initialized state after all V1 indices have been processed. +- Mutators are phase-gated: migration mutators are blocked after init, and post-init mutators are blocked before init. + +### Input Validation And Safety Checks + +ValidatorConfig V2 enforces the following checks: + +1. Validator ed25519 public keys must be unique across all validators (active and inactive). +2. Validator addresses must be unique across active validators. +3. `ingress` (formerly `inbound`) must be a valid `IP:port`, and ingress IP must be unique across active validators. +4. `egress` (formerly `outbound`) must be a valid IP. +5. `addValidator` and `rotateValidator` require a signature from the Ed25519 key being installed. +6. Each validator maintains a constant `idx` value in the validators array. + +### Ed25519 Signature Verification + +When adding or rotating a validator, the caller must provide an Ed25519 signature proving ownership of the public key. **Namespace:** `addValidator` uses `b"TEMPO_VALIDATOR_CONFIG_V2_ADD_VALIDATOR"` and `rotateValidator` uses `b"TEMPO_VALIDATOR_CONFIG_V2_ROTATE_VALIDATOR"`. **Message:** + ``` message = keccak256( bytes8(chainId) // uint64: Prevents cross-chain replay @@ -270,120 +283,122 @@ message = keccak256( ) ``` -The Ed25519 signature is computed over the message using the namespace parameter (see commonware's [signing scheme](https://github.com/commonwarexyz/monorepo/blob/abb883b4a8b42b362d4003b510bd644361eb3953/cryptography/src/ed25519/scheme.rs#L38-L40) and [union format](https://github.com/commonwarexyz/monorepo/blob/abb883b4a8b42b362d4003b510bd644361eb3953/utils/src/lib.rs#L166-L174)). +The Ed25519 signature is computed over this message with the namespace parameter (see commonware's [signing scheme](https://github.com/commonwarexyz/monorepo/blob/abb883b4a8b42b362d4003b510bd644361eb3953/cryptography/src/ed25519/scheme.rs#L38-L40) and [union format](https://github.com/commonwarexyz/monorepo/blob/abb883b4a8b42b362d4003b510bd644361eb3953/utils/src/lib.rs#L166-L174)). -### TODO: Consensus Layer Usage - +This read switch is implemented in CL logic. V2 does not proxy reads to V1. -## Migration +## Consensus Layer Integration -On networks that start directly with V2 (no V1 state), `initializeIfMigrated` MAY be called immediately when the V1 validator count is zero. +**IP address changes**: `setIpAddresses` is expected to take effect in CL peer configuration on the next finalized block. -Validator migration must be carefully designed and executed to prevent footguns that could cause consensus to break down, triggering a chain halt. Due to the high gas costs of `SSTORE` imposed by TIP-1000, migration is performed manually and validator-by-validator to ensure that there is no risk of too many validators causing the migration transaction to fail from out-of-gas. +**Validator addition and deactivation**: there is no warmup or cooldown in V2. Added validators join on the next epoch; deactivated validators leave on the next epoch. -### Migration Process +### DKG Player Selection -1. At the hardfork timestamp or block number, ValidatorConfig V2 becomes available on the network, but the consensus layer continues reading ValidatorConfig V1 during migration. -2. The owner calls `migrateValidator(n-1)`, where `n` is the total number of validators in validator config V1. -3. That first call copies `owner` from V1 if the V2 owner is unset. The owner then migrates remaining indices in descending order (`n-2` down to `0`). -4. When all validator entries have been copied over, the owner calls `initializeIfMigrated()` which checks that all validators have been migrated over. It sets `initialized` to true which signals to the CL to start reading from validator config V2 instead of V1. -5. The owner collects addresses from each validator and calls `transferValidatorOwnership` for each entry to enable permissionless rotation. +The consensus layer determines DKG players for epoch `E+1` by reading state at `boundary(E)` and filtering: -### Migration Edge Cases +``` +players(E+1) = validators.filter(v => + v.addedAtHeight <= boundary(E) && + (v.deactivatedAtHeight == 0 || v.deactivatedAtHeight > boundary(E)) +) +``` -#### 1. A validator goes offline during the migration period +This enables node recovery and late joining without historical account state. +--> -During migration, the chain still reads V1. If a validator goes offline, the admin should deactivate it in V1 and if already migrated, also deactivate it in V2. +## Migration -#### 2. A validator entry in V1 is invalid +On networks that start directly with V2 (no V1 state), `initializeIfMigrated` can be called immediately when the V1 validator count is zero. -As checks move from the consensus layer into the precompile, migration must avoid introducing invalid values that could halt the chain. We do not need to guard against invalid IP addresses or duplicate public keys because this is impossible in ValidatorConfig V1. +Because `SSTORE` cost is high under TIP-1000, migration is done one validator at a time to reduce out-of-gas risk on large sets. -The strategy on encountering invalid validator entry values is to skip migrating that specific index. This happens when ed25519 public keys are invalid or if duplicate ingress IPs exist. For the case of duplicated ed25519 public keys, the implementation must overwrite the validator slot. When an overwrite or a skip happens, we should track that this validator index was processed as not to block migration. +### Migration Process -### Permitted Calls to V2 during Migration +1. At fork activation, V2 is available but CL continues reading V1. +2. The owner calls `migrateValidator(n-1)` for V1 validator count `n`. +3. On the first migration call, V2 copies owner from V1 if unset, then continues in descending index order. +4. After all indices are processed, owner calls `initializeIfMigrated()`, which flips `initialized` and activates CL reads from V2. -The owner is able to call: `deactivateValidator`, `transferOwnership`, `setIpAddresses`, `migrateValidator` and `initializeIfMigrated`. `migrateValidator` and `initializeIfMigrated` cannot be called once the contract is initialized. +### Migration Edge Cases -In theory, validators can call `deactivateValidator` and `setIpAddresses`, but because the addresses are unowned on Tempo, they are unable to do so. +1. **Validator goes offline during migration**: admin deactivates the validator in V1 and, if that index has already been migrated, also deactivates it in V2. +2. **Invalid V1 entry encountered**: the index is still marked as processed (migrated, overwritten, or skipped by implementation rules) so global completion is not blocked. -### Permitted Calls to V1 during migration +### Permitted Calls During Migration -All calls are allowed to V1 during migration (after the hardfork timestamp). Note that since validator addresses are unowned, validators are unable to call any function on V1. +| Contract | Caller | Allowed calls | +| --- | --- | --- | +| V2 (pre-init) | owner | `deactivateValidator`, `transferOwnership`, `setIpAddresses`, `migrateValidator`, `initializeIfMigrated` | +| V2 (pre-init) | validator | `deactivateValidator`, `setIpAddresses` (theoretically; in Tempo these addresses are unowned) | +| V2 (post-init) | any | `migrateValidator` and `initializeIfMigrated` are blocked | +| V1 (during migration window) | owner and validators | all V1 calls remain available (subject to V1 authorization and key ownership assumptions) | # Security ## Considerations -- **Migration timing**: Migration must be completed (including `initializeIfMigrated()`) before an epoch boundary to avoid disrupting DKG. The admin should schedule migration during a period with sufficient time buffer before the next epoch transition. - -- **Checking edge cases**: Because ValidatorConfig V1 is admin-controlled, the admin should run a pre-migration validation script to detect values that would fail V2 checks. - -- **Differences in validator state in V1 and V2**: Admins must take precautions to ensure that state between v1 and v2 are consistent before calling `initialized`. This is best done with the same foundry script as above. +- **Migration timing**: migration and `initializeIfMigrated()` should complete before an epoch boundary to avoid DKG disruption. +- **Pre-migration validation**: admins should run a validation script against V1 state to detect entries that would fail V2 checks. +- **State parity before init**: admins should verify V1/V2 state consistency before finalizing with `initializeIfMigrated()`. +- **Signature domain separation**: signatures for `addValidator` and `rotateValidator` are bound to chain ID, precompile address, namespace, validator address, and endpoint payload. -- **Signature domain separation**: `addValidator` and `rotateValidator` signatures must be bound to chain ID, precompile address, function namespace, validator address, and endpoint payload to prevent cross-chain, cross-contract, or cross-function replay. +## Race And Griefing Risks -## Race conditions for state-changing calls +- Stable `index` values prevent races between concurrent state-changing calls. +- Append-only growth and permissionless rotation require gas-aware pagination to remain safe as history grows. -The implementation should keep validator indices stable to prevent race conditions across state-changing operations. +## Testing Requirements -## Griefing attacks - -Because `rotateValidator` is permissionless and state is append-only, array getters should be designed to remain safe under unbounded growth from repeated rotations. - -## Test Cases - -There should be unit tests covering all possible branches in added functions. +Unit tests should cover all control-flow branches in added functions, including initialization gating, migration completion checks, and pagination behavior under large validator sets. ## Invariants +### Identity and Uniqueness + 1. **Unique active addresses**: No two active validators share the same `validatorAddress`. Deactivated addresses may be reused. 2. **Unique public keys**: No two validators (including deactivated) share the same `publicKey`. -3. **Append-only validator array**: `validatorsArray` length can only increase. -4. **Entry index immutability**: Once a validator entry is created at index `i`, that entry can never move to another index. A previously deactivated operator may later be re-added as a new entry at a different index. -5. **Deactivate-once**: `deactivatedAtHeight` can only transition from 0 to a non-zero value, never back. -6. **Deactivated validator immutability**: Once `deactivatedAtHeight` is non-zero, that validator entry is immutable and can never become active again. -7. **Valid public keys**: All validators must have valid ed25519 `publicKey`s. -8. **Consistent validator index**: Validator `index` equals its position in `validatorsArray`. -9. **Safe pagination behavior**: `getValidators(startIndex)` may return a partial page due to gas limits, but must not revert solely because the remaining set is large. -10. **Full-set reconstruction by pagination**: Repeated `getValidators(startIndex)` calls (advancing `startIndex` by the number of entries returned each time) must reconstruct exactly the ordered set `validatorByIndex(0..validatorCount-1)`. -11. **Validator activity consistency**: Filtering the fully reconstructed validator set by `deactivatedAtHeight == 0` must produce exactly `getActiveValidators()` (same members, same order). -12. **Address round-trip for active entries**: For any `i` where `validatorByIndex(i).deactivatedAtHeight == 0`, `validatorByAddress(validatorByIndex(i).validatorAddress).index == i`. -13. **Public-key round-trip for all entries**: For any `i < validatorCount()`, `validatorByPublicKey(validatorByIndex(i).publicKey).index == i`. -14. **Ingress uniqueness across active validators**: In `getActiveValidators()`, no two validators share the same ingress IP component. -15. **Rotation preserves active cardinality**: A successful `rotateValidator` call does not change `getActiveValidators().length`. -16. **Deactivation decrements active cardinality by one**: A successful `deactivateValidator` call decreases `getActiveValidators().length` by exactly one. -17. **Rotate appends exactly one entry**: A successful `rotateValidator` call increases `validatorCount()` by exactly one. -18. **Initialization phase gating**: Before initialization, post-init mutators are blocked; after initialization, migration mutators are blocked. -19. **Initialized once**: The `initialized` flag can only transition from `false` to `true`, never back. -20. **Migration completion gate**: Each V1 index must be processed exactly once (migrated or skipped), and `initializeIfMigrated()` stays blocked until all indices are processed. -21. **Skipped-index counter monotonicity**: `migrationSkippedCount` is monotonically non-decreasing and may only change during `migrateValidator`. -22. **DKG continuity at initialization**: On successful `initializeIfMigrated`, `getNextFullDkgCeremony()` in V2 equals the value read from V1 at that moment. -23. **Owner bootstrap during migration**: If V2 owner is unset on first migration call, owner is copied from V1 exactly once and then used for all migration authorization checks. +3. **Ingress uniqueness across active validators**: In `getActiveValidators()`, no two validators share the same ingress IP component. +4. **Valid public keys**: All validators must have valid ed25519 `publicKey`s. + +### Lifecycle and Storage Behavior + +1. **Append-only validator array**: `validatorsArray` length can only increase. +2. **Entry index immutability**: Once a validator entry is created at index `i`, that entry can never move to another index. A previously deactivated operator may later be re-added as a new entry at a different index. +3. **Deactivate-once**: `deactivatedAtHeight` can only transition from 0 to a non-zero value, never back. +4. **Add increases exactly one entry**: A successful `addValidator` call increases `getActiveValidators().length` by exactly one. +5. **Rotation preserves active cardinality**: A successful `rotateValidator` call does not change `getActiveValidators().length`. +6. **Deactivation decrements active cardinality by one**: A successful `deactivateValidator` call decreases `getActiveValidators().length` by exactly one. + +### Query and Pagination Correctness + +1. **Safe pagination behavior**: `getValidators(startIndex)` may return a partial page due to gas limits, but must not revert solely because the remaining set is large. +2. **Full-set reconstruction by pagination**: Repeated `getValidators(startIndex)` calls (advancing `startIndex` by the number of entries returned each time) must reconstruct exactly the ordered set `validatorByIndex(0..validatorCount-1)`. +3. **Validator activity consistency**: Filtering the fully reconstructed validator set by `deactivatedAtHeight == 0` must produce exactly `getActiveValidators()` (same members, same order). +4. **Address round-trip for active entries**: For any `i` where `validatorByIndex(i).deactivatedAtHeight == 0`, `validatorByAddress(validatorByIndex(i).validatorAddress).index == i`. +5. **Public-key round-trip for all entries**: For any `i < validatorCount()`, `validatorByPublicKey(validatorByIndex(i).publicKey).index == i`. +6. **Index round-trip for all entries**: For any `i < validatorCount()`, `validatorByIndex(i).index == i`. + +### Migration and Initialization + +1. **Initialization phase gating**: Before initialization, post-init mutators are blocked; after initialization, migration mutators are blocked. +2. **Initialized once**: The `initialized` flag can only transition from `false` to `true`, never back. +3. **Migration completion gate**: Each V1 index must be processed exactly once (migrated or skipped), and `initializeIfMigrated()` stays blocked until all indices are processed. +4. **Skipped-index counter monotonicity**: `migrationSkippedCount` is monotonically non-decreasing and may only change during `migrateValidator`. +5. **DKG continuity at initialization**: On successful `initializeIfMigrated`, `getNextFullDkgCeremony()` in V2 equals the value read from V1 at that moment. +6. **Owner bootstrap during migration**: If V2 owner is unset on first migration call, owner is copied from V1 exactly once and then used for all migration authorization checks. From 0809e66e288baf5ad1272985e9208da244d9ed70 Mon Sep 17 00:00:00 2001 From: Richard Janis Goldschmidt Date: Tue, 3 Mar 2026 20:32:31 +0100 Subject: [PATCH 06/10] feat: add feeRecipient to tip, ref impl (#2954) Adds the `feeRecipient` field to val config v2 `Validator` entries. Also introduces the `setFeeRecipient` function to change the fee recipient, and `FeeRecipientUpdated` event. Unclear how this should be added to the spec: 1. this is not expected to be activated until T3. This is in preparation for T3 but won't be implemented in T2. 2. during V1 -> V2 migration, the value is expected to be set to `0x0`. --------- Co-authored-by: howy <132113803+howydev@users.noreply.github.com> Co-authored-by: Amp --- tips/tip-1017.md | 38 +++++++++++++++++++++++++++++--------- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/tips/tip-1017.md b/tips/tip-1017.md index 65d860798c..6bc82819e4 100644 --- a/tips/tip-1017.md +++ b/tips/tip-1017.md @@ -10,7 +10,7 @@ status: Draft ## Abstract -TIP-1017 defines ValidatorConfig V2, a new precompile for managing consensus participants. V2 improves lifecycle tracking so validator sets can be reconstructed for any epoch, and adds stricter input validation. It also aims to safely support permissionless validator rotation. +TIP-1017 defines ValidatorConfig V2, a new precompile for managing consensus participants. V2 improves lifecycle tracking so validator sets can be reconstructed for any epoch, and adds stricter input validation. It is designed to safely support permissionless validator rotation, and additionally allows separation of fee custody from day-to-day validator operations. ## Motivation @@ -91,6 +91,7 @@ interface IValidatorConfigV2 { /// @param index Immutable validators-array position. /// @param addedAtHeight Block height when entry was added. /// @param deactivatedAtHeight Block height when entry was deactivated (`0` if active). + /// @param feeRecipient The fee recipient the node will set when proposing blocks as a leader. struct Validator { bytes32 publicKey; address validatorAddress; @@ -99,6 +100,7 @@ interface IValidatorConfigV2 { uint64 index; uint64 addedAtHeight; uint64 deactivatedAtHeight; + address feeRecipient; } /// @notice Get validators in array order starting at `idx`. @@ -144,12 +146,14 @@ interface IValidatorConfigV2 { /// @param publicKey Validator Ed25519 communication public key. /// @param ingress Inbound address `:`. /// @param egress Outbound address ``. + /// @param feeRecipient The fee recipient the validator sets when proposing. /// @param signature Ed25519 signature proving key ownership. function addValidator( address validatorAddress, bytes32 publicKey, string calldata ingress, string calldata egress, + address feeRecipient, bytes calldata signature ) external returns (uint64); @@ -183,6 +187,14 @@ interface IValidatorConfigV2 { string calldata egress ) external; + /// @notice Update validator fee recipient (owner or validator only). + /// @param idx Validator index. + /// @param feeRecipient New fee recipient. + function setFeeRecipient( + uint64 idx, + address feeRecipient + ) external; + /// @notice Transfer validator entry to a new address (owner or validator only). /// @dev Reverts if `newAddress` conflicts with an active validator. /// @param idx Validator index. @@ -244,8 +256,13 @@ V2 stores validators in one append-only array, with lookup indexes by address an ### Network And Ownership Operations - `setIpAddresses`: updates ingress and egress for an active validator, enforcing address format and ingress uniqueness among active entries. +- `setFeeRecipient`: updates the destination address that receives network fees from block proposing. - `transferValidatorOwnership`: rebinds a validator entry to a new address provided the address is not used by another active entry. +### Fee Recipient Separation + +Each validator entry includes a `feeRecipient` that can differ from the validator's control address. This enables operators to route protocol fees to a dedicated treasury wallet, while retaining a separate validator or treasury-ops multisig for operational calls. This separation reduces blast radius during key compromise: operational key exposure can be remediated without automatically exposing historical fee balances held by the custody wallet. + ### Migration And Phase-Gating Operations - `migrateValidator`: copies one V1 entry into V2 in descending index order. @@ -280,6 +297,7 @@ message = keccak256( || ingress // string: Binds network configuration || uint8(egress.length) // uint8: Length of egress || egress // string: Binds network configuration + || feeRecipient // address: Binds fee recipients when proposing. ) ``` @@ -293,9 +311,9 @@ The Ed25519 signature is computed over this message with the namespace parameter 2. V2 enforces stronger input checks in the precompile, including signature-backed key ownership. 3. V2 keeps validator index stable across lifecycle operations. -### CL Read Behavior - ## Migration From 48cbbb4157e7e6aa9befba030727ad019d67cb69 Mon Sep 17 00:00:00 2001 From: howydev <132113803+howydev@users.noreply.github.com> Date: Tue, 3 Mar 2026 14:44:06 -0500 Subject: [PATCH 07/10] docs(tip-1017): remove getValidators and use index-based invariants Amp-Thread-ID: https://ampcode.com/threads/T-019cb530-4354-7302-955e-a2358a1c8f00 Co-authored-by: Amp --- tips/tip-1017.md | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/tips/tip-1017.md b/tips/tip-1017.md index 6bc82819e4..06ca71e4d0 100644 --- a/tips/tip-1017.md +++ b/tips/tip-1017.md @@ -103,12 +103,6 @@ interface IValidatorConfigV2 { address feeRecipient; } - /// @notice Get validators in array order starting at `idx`. - /// @dev May return a partial page when remaining gas is insufficient. - /// @param idx Start index in validators array. - /// @return validators Validators from `idx` onward. - function getValidators(uint64 idx) external view returns (Validator[] memory validators); - /// @notice Get active validators. /// @return validators Active validators (`deactivatedAtHeight == 0`). function getActiveValidators() external view returns (Validator[] memory validators); @@ -381,11 +375,11 @@ Because `SSTORE` cost is high under TIP-1000, migration is done one validator at ## Race And Griefing Risks - Stable `index` values prevent races between concurrent state-changing calls. -- Append-only growth and permissionless rotation require gas-aware pagination to remain safe as history grows. +- Append-only growth and permissionless rotation require query paths that remain safe as history grows. ## Testing Requirements -Unit tests should cover all control-flow branches in added functions, including initialization gating, migration completion checks, and pagination behavior under large validator sets. +Unit tests should cover all control-flow branches in added functions, including initialization gating, migration completion checks, and index-based query behavior under large validator sets. ## Invariants @@ -405,14 +399,13 @@ Unit tests should cover all control-flow branches in added functions, including 5. **Rotation preserves active cardinality**: A successful `rotateValidator` call does not change `getActiveValidators().length`. 6. **Deactivation decrements active cardinality by one**: A successful `deactivateValidator` call decreases `getActiveValidators().length` by exactly one. -### Query and Pagination Correctness +### Query Correctness -1. **Safe pagination behavior**: `getValidators(startIndex)` may return a partial page due to gas limits, but must not revert solely because the remaining set is large. -2. **Full-set reconstruction by pagination**: Repeated `getValidators(startIndex)` calls (advancing `startIndex` by the number of entries returned each time) must reconstruct exactly the ordered set `validatorByIndex(0..validatorCount-1)`. -3. **Validator activity consistency**: Filtering the fully reconstructed validator set by `deactivatedAtHeight == 0` must produce exactly `getActiveValidators()` (same members, same order). -4. **Address round-trip for active entries**: For any `i` where `validatorByIndex(i).deactivatedAtHeight == 0`, `validatorByAddress(validatorByIndex(i).validatorAddress).index == i`. -5. **Public-key round-trip for all entries**: For any `i < validatorCount()`, `validatorByPublicKey(validatorByIndex(i).publicKey).index == i`. -6. **Index round-trip for all entries**: For any `i < validatorCount()`, `validatorByIndex(i).index == i`. +1. **Full-set reconstruction by index**: Reading `validatorByIndex(i)` for all `i` in `0..validatorCount()-1` must reconstruct exactly the ordered validator set. +2. **Validator activity consistency**: Filtering the reconstructed validator set by `deactivatedAtHeight == 0` must produce exactly `getActiveValidators()` (same members, same order). +3. **Address round-trip for active entries**: For any `i` where `validatorByIndex(i).deactivatedAtHeight == 0`, `validatorByAddress(validatorByIndex(i).validatorAddress).index == i`. +4. **Public-key round-trip for all entries**: For any `i < validatorCount()`, `validatorByPublicKey(validatorByIndex(i).publicKey).index == i`. +5. **Index round-trip for all entries**: For any `i < validatorCount()`, `validatorByIndex(i).index == i`. ### Migration and Initialization From 0bf3cb5910577c13d111ec625c40eaec4247e5a4 Mon Sep 17 00:00:00 2001 From: howydev <132113803+howydev@users.noreply.github.com> Date: Tue, 3 Mar 2026 14:46:20 -0500 Subject: [PATCH 08/10] docs(tip-1017): clarify active-validator invariant ordering Amp-Thread-ID: https://ampcode.com/threads/T-019cb530-4354-7302-955e-a2358a1c8f00 Co-authored-by: Amp --- tips/tip-1017.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tips/tip-1017.md b/tips/tip-1017.md index 06ca71e4d0..64a326f702 100644 --- a/tips/tip-1017.md +++ b/tips/tip-1017.md @@ -402,7 +402,7 @@ Unit tests should cover all control-flow branches in added functions, including ### Query Correctness 1. **Full-set reconstruction by index**: Reading `validatorByIndex(i)` for all `i` in `0..validatorCount()-1` must reconstruct exactly the ordered validator set. -2. **Validator activity consistency**: Filtering the reconstructed validator set by `deactivatedAtHeight == 0` must produce exactly `getActiveValidators()` (same members, same order). +2. **Validator activity consistency**: Filtering the reconstructed validator set by `deactivatedAtHeight == 0` must produce exactly `getActiveValidators()` (same members, order not important). 3. **Address round-trip for active entries**: For any `i` where `validatorByIndex(i).deactivatedAtHeight == 0`, `validatorByAddress(validatorByIndex(i).validatorAddress).index == i`. 4. **Public-key round-trip for all entries**: For any `i < validatorCount()`, `validatorByPublicKey(validatorByIndex(i).publicKey).index == i`. 5. **Index round-trip for all entries**: For any `i < validatorCount()`, `validatorByIndex(i).index == i`. From 0bcc659ef04cbc8262fd2fbe40a62f68b09c46dc Mon Sep 17 00:00:00 2001 From: howydev <132113803+howydev@users.noreply.github.com> Date: Tue, 3 Mar 2026 14:48:43 -0500 Subject: [PATCH 09/10] chore: improve location of fee recipient block --- tips/tip-1017.md | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/tips/tip-1017.md b/tips/tip-1017.md index 64a326f702..e7c1771bab 100644 --- a/tips/tip-1017.md +++ b/tips/tip-1017.md @@ -225,7 +225,7 @@ interface IValidatorConfigV2 { - Migration incrementally reads and copies validator entries from V1 into V2. - During migration, the consensus layer continues reading V1 until `initializeIfMigrated()` completes. -- Validator entries are append-only and follow deactivate-once semantics. +- Validator history are append-only, and deactivation is one-way. - Historical validator sets are reconstructed from `addedAtHeight` and `deactivatedAtHeight`. - Validator `index` is stable for the lifetime of an entry. - Writes for post-migration operations are gated by `isInitialized()`. @@ -239,13 +239,17 @@ V2 stores validators in one append-only array, with lookup indexes by address an - `index`: immutable array position assigned at creation. - `initialized`: one-way migration flag toggled by `initializeIfMigrated()`. +### Fee Recipient Separation + +Each validator entry includes a `feeRecipient` that can differ from the validator's control address. This enables operators to route protocol fees to a dedicated treasury wallet, while retaining a separate validator or treasury-ops multisig for operational calls. This separation reduces blast radius during key compromise: operational key exposure does not cause historically collected fees held by the custody wallet to be lost. + ## Operation Semantics ### Lifecycle Operations - `addValidator`: appends a new active entry after validation and signature verification. -- `deactivateValidator`: marks an existing active entry as deactivated at current height. -- `rotateValidator`: deactivates the current active entry and appends its replacement, keeping the active validator count unchanged. +- `deactivateValidator`: marks an existing active entry as deactivated at current block height. +- `rotateValidator`: to keep `index` stable, this updates the active entry in place and appends the entry to be deactivated. Active validator count is unchanged. ### Network And Ownership Operations @@ -253,10 +257,6 @@ V2 stores validators in one append-only array, with lookup indexes by address an - `setFeeRecipient`: updates the destination address that receives network fees from block proposing. - `transferValidatorOwnership`: rebinds a validator entry to a new address provided the address is not used by another active entry. -### Fee Recipient Separation - -Each validator entry includes a `feeRecipient` that can differ from the validator's control address. This enables operators to route protocol fees to a dedicated treasury wallet, while retaining a separate validator or treasury-ops multisig for operational calls. This separation reduces blast radius during key compromise: operational key exposure can be remediated without automatically exposing historical fee balances held by the custody wallet. - ### Migration And Phase-Gating Operations - `migrateValidator`: copies one V1 entry into V2 in descending index order. @@ -272,7 +272,6 @@ ValidatorConfig V2 enforces the following checks: 3. `ingress` (formerly `inbound`) must be a valid `IP:port`, and ingress IP must be unique across active validators. 4. `egress` (formerly `outbound`) must be a valid IP. 5. `addValidator` and `rotateValidator` require a signature from the Ed25519 key being installed. -6. Each validator maintains a constant `idx` value in the validators array. ### Ed25519 Signature Verification @@ -301,7 +300,7 @@ The Ed25519 signature is computed over this message with the namespace parameter ### Changes From V1 -1. V2 uses append-only entries with irreversible deactivation instead of mutable active/inactive toggling. +1. V2 preserves append-only history with irreversible deactivation instead of mutable active/inactive toggling. 2. V2 enforces stronger input checks in the precompile, including signature-backed key ownership. 3. V2 keeps validator index stable across lifecycle operations. @@ -336,22 +335,22 @@ players(E+1) = validators.filter(v => This enables node recovery and late joining without historical account state. -## Migration +## Migration from V1 On networks that start directly with V2 (no V1 state), `initializeIfMigrated` can be called immediately when the V1 validator count is zero. Because `SSTORE` cost is high under TIP-1000, migration is done one validator at a time to reduce out-of-gas risk on large sets. -### Migration Process +### Full Migration Steps -1. At fork activation, V2 is available but CL continues reading V1. -2. The owner calls `migrateValidator(n-1)` for V1 validator count `n`. +1. At fork activation, the V2 precompile goes live. However, CL continues reading from the V1 precompile. +2. The owner calls `migrateValidator(n-1)` with `n` being the validator count in the V1 precompile. 3. On the first migration call, V2 copies owner from V1 if unset, then continues in descending index order. 4. After all indices are processed, owner calls `initializeIfMigrated()`, which flips `initialized` and activates CL reads from V2. ### Migration Edge Cases -1. **Validator goes offline during migration**: admin deactivates the validator in V1 and, if that index has already been migrated, also deactivates it in V2. +1. **Validator goes offline during migration**: admin deactivates the validator in V1. If that index has already been migrated the admin will also deactivates it in V2. 2. **Invalid V1 entry encountered**: the index is still marked as processed (migrated, overwritten, or skipped by implementation rules) so global completion is not blocked. ### Permitted Calls During Migration @@ -375,7 +374,7 @@ Because `SSTORE` cost is high under TIP-1000, migration is done one validator at ## Race And Griefing Risks - Stable `index` values prevent races between concurrent state-changing calls. -- Append-only growth and permissionless rotation require query paths that remain safe as history grows. +- Append-only history and permissionless rotation require query paths that remain safe as history grows. ## Testing Requirements From e002bcc8c7d539675d07afc32413254735128011 Mon Sep 17 00:00:00 2001 From: howydev <132113803+howydev@users.noreply.github.com> Date: Tue, 3 Mar 2026 15:59:16 -0500 Subject: [PATCH 10/10] docs(tip-1017): split add and rotate signature messages Amp-Thread-ID: https://ampcode.com/threads/T-019cb540-7de9-75a3-88bb-b100a06a7eed Co-authored-by: Amp --- tips/tip-1017.md | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/tips/tip-1017.md b/tips/tip-1017.md index e7c1771bab..a2c8ca58f2 100644 --- a/tips/tip-1017.md +++ b/tips/tip-1017.md @@ -279,10 +279,10 @@ When adding or rotating a validator, the caller must provide an Ed25519 signatur **Namespace:** `addValidator` uses `b"TEMPO_VALIDATOR_CONFIG_V2_ADD_VALIDATOR"` and `rotateValidator` uses `b"TEMPO_VALIDATOR_CONFIG_V2_ROTATE_VALIDATOR"`. -**Message:** +**Messages:** ``` -message = keccak256( +addValidatorMessage = keccak256( bytes8(chainId) // uint64: Prevents cross-chain replay || contractAddress // address: Prevents cross-contract replay || validatorAddress // address: Binds to specific validator address @@ -292,9 +292,19 @@ message = keccak256( || egress // string: Binds network configuration || feeRecipient // address: Binds fee recipients when proposing. ) + +rotateValidatorMessage = keccak256( + bytes8(chainId) // uint64: Prevents cross-chain replay + || contractAddress // address: Prevents cross-contract replay + || validatorAddress // address: Binds to specific validator address + || uint8(ingress.length) // uint8: Length of ingress + || ingress // string: Binds network configuration + || uint8(egress.length) // uint8: Length of egress + || egress // string: Binds network configuration +) ``` -The Ed25519 signature is computed over this message with the namespace parameter (see commonware's [signing scheme](https://github.com/commonwarexyz/monorepo/blob/abb883b4a8b42b362d4003b510bd644361eb3953/cryptography/src/ed25519/scheme.rs#L38-L40) and [union format](https://github.com/commonwarexyz/monorepo/blob/abb883b4a8b42b362d4003b510bd644361eb3953/utils/src/lib.rs#L166-L174)). +The Ed25519 signature is computed over the operation-specific message with the namespace parameter (see commonware's [signing scheme](https://github.com/commonwarexyz/monorepo/blob/abb883b4a8b42b362d4003b510bd644361eb3953/cryptography/src/ed25519/scheme.rs#L38-L40) and [union format](https://github.com/commonwarexyz/monorepo/blob/abb883b4a8b42b362d4003b510bd644361eb3953/utils/src/lib.rs#L166-L174)). ## Compatibility And Upgrade Behavior