diff --git a/tips/tip-1017.md b/tips/tip-1017.md index 1478cf74c4..a2c8ca58f2 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), Howy (@howydev) +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 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 -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 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 ## Precompile Address - ```solidity address constant VALIDATOR_CONFIG_V2_ADDRESS = 0xCCCCCCCC00000000000000000000000000000001; ``` @@ -79,57 +30,68 @@ 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 -/// @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) +/// @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 delete a validator that is already deleted - error ValidatorAlreadyDeleted(); + /// @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 the Ed25519 signature verification fails + /// @notice Validator address is invalid. + error InvalidValidatorAddress(); + + /// @notice Ed25519 signature verification failed. 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); + /// @notice Contract is not initialized. + error NotInitialized(); + + /// @notice Contract is already initialized. + error AlreadyInitialized(); + + /// @notice Migration is not complete. + error MigrationNotComplete(); + + /// @notice Migration index is out of order. + error InvalidMigrationIndex(); + + /// @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 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, 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 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) + /// @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). + /// @param feeRecipient The fee recipient the node will set when proposing blocks as a leader. struct Validator { bytes32 publicKey; address validatorAddress; @@ -138,671 +100,327 @@ interface IValidatorConfigV2 { uint64 index; uint64 addedAtHeight; uint64 deactivatedAtHeight; + address feeRecipient; } - /// @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 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 + /// @notice Get contract owner. + /// @return Owner address. function owner() external view returns (address); - /// @notice Get total number of validators ever added (including deleted) - /// @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(abi.encodePacked(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. - /// @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 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; - - /// @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. - /// - /// @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". - /// @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 - /// @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 + ) external returns (uint64); + + /// @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 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( - address validatorAddress, + uint64 idx, bytes32 publicKey, string calldata ingress, string calldata egress, 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 validatorAddress The address 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( - address validatorAddress, + 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 currentAddress The current address of the validator to transfer - /// @param newAddress The new address to assign to the validator - function transferValidatorOwnership(address currentAddress, address newAddress) 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. + /// @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 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). - /// @param idx Index of the validator in V1 validators array (must equal current validatorsArray.length) + /// @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 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` + /// @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); -} -``` - -## Behavior - -### 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 + /// @notice Get initialization block height. + /// @return height Initialization height (`0` if not initialized). + function getInitializedAtHeight() external view returns (uint64); +} ``` -┌─────────────┐ 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"` +## Overview -**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 -)) -``` +- Migration incrementally reads and copies validator entries from V1 into V2. +- During migration, the consensus layer continues reading V1 until `initializeIfMigrated()` completes. +- 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()`. -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)). +## State Model -For validator rotations, the namespace `b"TEMPO_VALIDATOR_CONFIG_V2_ROTATE_VALIDATOR"` is used instead. +V2 stores validators in one append-only array, with lookup indexes by address and public key. -### Determining Active Validators +- `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()`. -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. +### Fee Recipient Separation -To determine the actual validators for epoch `E+1`: +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. -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 +## Operation Semantics -``` -activeValidators(E+1) = dkgOutcome(boundary(E)).players.map(pubkey => - contract.validatorByPublicKey(pubkey) -) -``` +### Lifecycle Operations -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` +- `addValidator`: appends a new active entry after validation and signature verification. +- `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. -### Address Validation +### Network And Ownership Operations -- **ingress**: Must be in `:` format. -- **egress**: Must be in `` format. +- `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. -Both IPv4 and IPv6 addresses are supported. For ingress, IPv6 addresses must be -enclosed in brackets: `[2001:db8::1]:8080`. +### Migration And Phase-Gating Operations -### IP Address Uniqueness +- `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. -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 +### Input Validation And Safety Checks -**Implementation**: Tracked via storage mapping (`active_ingress_ips: Mapping`) where keys are `keccak256(ingressIp)` for O(1) validation. +ValidatorConfig V2 enforces the following checks: -**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) +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. -### Consensus Layer Behavior +### Ed25519 Signature Verification -**IP Address Changes**: When a validator's IP address changes via `setIpAddresses`, the consensus layer is expected to update its peer list on the next finalized block. +When adding or rotating a validator, the caller must provide an Ed25519 signature proving ownership of the public key. -**Validator Addition and Deactivation**: When validators are added or deleted (this also applies to rotation), -there is no warmup period: deactivated validators are immediately removed from the set of players on the next epoch, -while activated validators are immediately added on the next epoch. This means that compared to validator config V1, -there is no cooldown and no warmup period. +**Namespace:** `addValidator` uses `b"TEMPO_VALIDATOR_CONFIG_V2_ADD_VALIDATOR"` and `rotateValidator` uses `b"TEMPO_VALIDATOR_CONFIG_V2_ROTATE_VALIDATOR"`. -**DKG Player Selection**: The consensus layer determines DKG players for epoch `E+1` by reading state at `boundary(E)` and filtering: +**Messages:** ``` -players(E+1) = validators.filter(v => - v.addedAtHeight <= boundary(E) && - (v.deactivatedAtHeight == 0 || v.deactivatedAtHeight > boundary(E)) +addValidatorMessage = 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 + || feeRecipient // address: Binds fee recipients when proposing. ) -``` - -This enables nodes to reconstruct DKG player sets without accessing historical account state—critical for node recovery and late-joining validators. -### Uniqueness Constraints - -Both the validator address and public key must be globally unique across all -validators (including deleted ones): - -- `AddressAlreadyHasValidator`: Reverts if the address has ever been registered -- `PublicKeyAlreadyExists`: Reverts if the public key has ever been registered - -This ensures historical queries always return consistent results. - -## Storage Layout - -The contract stores the following state: -- `owner`: Contract owner address -- `initialized`: Whether V2 has been initialized from V1 (boolean) -- `initializedAtHeight`: Block height when V2 was initialized (uint64) -- `validatorsArray`: Append-only array of all validators (Validator[]) -- `activeValidators`: array of indices into `validatorsArray` (uint64[]) -- `addressToIndex`: Mapping from validator address to array index (mapping) -- `pubkeyToIndex`: Mapping from public key to array index (mapping) -- `nextDkgCeremony`: Next full DKG ceremony epoch (uint64) -- `active_ingress_ips`: Mapping from `keccak256(ingressIp)` to bool (Mapping) - -Implementation details such as slot packing are left to the implementation. - -## Differences from V1 - -| Aspect | V1 | V2 | -|--------|----|----| -| Status field | `active: bool` | `deactivatedAtHeight: uint64` (0 = active) | -| Creation tracking | None | `addedAtHeight: uint64` | -| Mutability | Mutable via `updateValidator()` | Immutable after creation | -| Deletion | Sets `active = false` | Sets `deactivatedAtHeight = block.number` | -| Re-registration | Allowed after deletion | Pubkey reserved forever; address reassignable via `transferValidatorOwnership` | -| Key ownership | Not verified | Ed25519 signature required | -| Historical queries | Requires historical state | Query via `validatorByPublicKey` and filter by `addedAtHeight`/`deactivatedAtHeight` | -| Uniqueness | Address only | Address AND public key | -| Precompile address | `0xCCCC...0000` | `0xCCCC...0001` | - ---- - -# Invariants - -The following invariants must always hold: - -1. **Append-only array**: The `validatorsArray` length only increases; it never - decreases. - -2. **Immutable identity**: Once a validator is added, its `publicKey`, - `index`, and `addedAtHeight` fields never change. The `ingress` and `egress` - fields can be updated via `setIpAddresses`. `validatorAddress` can only be - changed by the contract owner. - -3. **Address update**: `transferValidatorOwnership` updates the address of an - existing validator without changing any other fields. This can be called - by the contract owner or by the validator themselves and is required for - post-migration fixes: validator contract v1 contains unusable dummy addresses - that are not usable. - -4. **Delete-once**: A validator's `deactivatedAtHeight` can only transition from 0 - to a non-zero value, never back to 0 or to a different non-zero value. - -5. **Unique addresses**: No two validators (including deleted ones) can have the - same `validatorAddress`. - -6. **Unique public keys**: No two validators (including deleted ones) can have - the same `publicKey`. - -7. **Non-zero public keys**: All validators must have a non-zero `publicKey`. - -8. **Monotonic index**: Validator `index` equals its position in - `validatorsArray` and equals `validatorCount - 1` at creation time. +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 +) +``` -9. **Historical consistency**: For any height H, the active validator set - consists of validators where `addedAtHeight <= H && (deactivatedAtHeight == 0 || - deactivatedAtHeight > H)`. Validators with `addedAtHeight == deactivatedAtHeight` are - never considered active. +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)). -10. **Signature binding**: The signature message includes `chainId`, - `contractAddress`, `validatorAddress`, `ingress`, and - `egress`, preventing replay across chains, contracts, or parameter - changes. +## Compatibility And Upgrade Behavior -11. **Owner authorization**: Only the owner can call `addValidator`, - `transferOwnership`, `migrateValidator`, `initializeIfMigrated`, and - `setNextFullDkgCeremony`. +### Changes From V1 -12. **Dual authorization**: - - `rotateValidator` and `transferValidatorOwnership` can be called by either - the owner or the validator itself. These functions require initialization. - - `deactivateValidator` and `setIpAddresses` can be called by the owner or the validator itself. - These functions do NOT require initialization and can be used during migration. +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. -13. **Initialized once**: The `initialized` bit (bit 255 of slot 0) can only - transition from 0 to 1, never back to 0. +### Consensus Layer Read Behavior -14. **Atomic rotation**: `rotateValidator` atomically deletes the old validator - (setting its `deactivatedAtHeight`), adds a new validator entry, and updates - the `addresses[validatorAddress]` mapping to point to the new entry. Both - operations occur in the same transaction with the same block height. +The Consensus Layer checks `v2.isInitialized()` to determine which contract to read: ---- +- **`initialized == false`**: CL reads from V1. +- **`initialized == true`**: CL reads from V2. -# Migration from V1 +This read switch is implemented in CL logic. V2 does not proxy reads to V1. -This section describes the migration strategy from ValidatorConfig V1 to V2. +## Consensus Layer Integration -## Overview +**IP address changes**: `setIpAddresses` is expected to take effect in CL peer configuration on the next finalized block. -The migration uses a two-pronged approach: +**Validator addition and deactivation**: there is no warmup or cooldown in V2. Added validators are added to the DKG player set on the next epoch; deactivated validators leave on the next epoch. +(both in the case of successful DKG rounds; on failure DKG still falls back to its previous state, which might include validators that are marked inactive as per the contract). -1. **New hardfork**: Timestamp-based activation -2. **Manual migration**: Admin migrates validators one at a time, then calls - `initializeIfMigrated()` to flip the flag. CL reads from V1 until the flag is set. +**Fee recipients**: Fee recipients are included now to be used in the future in a not yet determined hardfork. -## Hardfork-Based Switching +### DKG Player Selection -The CL determines which contract to read based on: -1. Whether hardfork is active (timestamp-based) -2. Whether V2 is initialized (reads `isInitialized()` from V2) +The consensus layer determines DKG players for epoch `E+1` by reading state at `boundary(E) - 1` and filtering: ``` -if chainspec.is__active_at_timestamp(block.timestamp) { - if v2.isInitialized() { - read_from_contract_v2_at_height(height) - } else { - read_from_contract_at_height(height) // V1 until migration complete - } -} else { - read_from_contract_at_height(height) // V1 -} +players(E+1) = validators.filter(v => + v.addedAtHeight < boundary(E) && + (v.deactivatedAtHeight == 0 || v.deactivatedAtHeight >= boundary(E)) +) ``` -This ensures: -- All nodes switch deterministically at hardfork time -- CL continues reading V1 until admin completes migration and flips the flag +This enables node recovery and late joining without historical account state. -## Manual Migration +## Migration from V1 -V2 uses manual migration where the admin explicitly migrates validators one at a -time and then calls `initializeIfMigrated()` to flip the `initialized` flag. The -`initialized` bit (bit 255 of slot 0) tracks whether migration is complete. +On networks that start directly with V2 (no V1 state), `initializeIfMigrated` can be called immediately when the V1 validator count is zero. -### Motivation +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 gas limit per transaction is 30 million as of [TIP-1010](./tip-1010.mdx#main-transaction-gas-limit), -with an `SSTORE` being 250 thousand gas as per [TIP-1000](./tip-1000.mdx#gas-schedule-summary). -This means migration of a single validator incurs at least 1 million gas cost, only -leaving enough room to migrate less than 30 validator entries at a time. +### Full Migration Steps -This runs the risk of potentially not leaving enough space to migrate all validators -in one go and would require logic to run several migrations. This would require -manual intervention anyway, and require extra logic in the precompile to check -which validators have already been migrated. +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. -### Validator Deactivation During Migration +### Migration Edge Cases -During the migration window (after `migrateValidator` calls begin but before `initializeIfMigrated()` is called): +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. -- **Deactivation is allowed**: Both validators (self-deactivation) and the owner can call `deactivateValidator()` before initialization -- This allows validators to opt-out and the owner to manage the validator set during the migration window +### Permitted Calls During Migration -Unlike other mutating operations (`addValidator`, `rotateValidator`, `transferValidatorOwnership`), -`deactivateValidator` and `setIpAddresses` do not require initialization. This design choice allows -flexible validator management and IP updates during the migration phase. +| 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) | -### CL Read Behavior +# Security -The CL checks `v2.isInitialized()` to determine which contract to read: +## Considerations -- **`initialized == false`**: CL reads from V1 -- **`initialized == true`**: CL reads from V2 +- **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. -This is handled entirely in the CL logic, not in the V2 precompile. The V2 -precompile does NOT proxy reads to V1. +## Race And Griefing Risks -### Migration Functions (Owner Only) +- Stable `index` values prevent races between concurrent state-changing calls. +- Append-only history and permissionless rotation require query paths that remain safe as history grows. -**`migrateValidator(idx)`**: -- Reverts if `isInitialized() == true` -- Reverts if `idx != validatorsArray.length` (ensures sequential migration) -- On first call (when `validatorsArray.length == 0`), copies `owner` from V1 if V2 owner is `address(0)` -- Reads the validator from V1 at index `idx` -- Creates a V2 validator entry with: - - `publicKey`: copied from V1 - - `validatorAddress`: copied from V1 - - `ingress`: copied from V1 `inboundAddress` - - `egress`: copied from V1 `outboundAddress` - - `index`: set to `idx` - - `addedAtHeight`: `block.height` - - `deactivatedAtHeight`: set to `0` if V1 `active == true`, otherwise `block.height` -- Adds to `validatorsArray` -- Populates lookup maps (`validators[validatorAddress]`, `pubkeyToIndex[pubkey]`) +## Testing Requirements -**`initializeIfMigrated()`**: -- Reverts if `validatorsArray.length < V1.getAllValidators().length` (ensures all validators migrated) -- Copies `nextDkgCeremony` from V1 to V2 -- Sets `initialized = true` (bit 255 of slot 0) -- After this call, CL reads from V2 instead of V1 +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. -**`transferValidatorOwnership(validatorAddress, newAddress)`**: -- Reverts if caller is not owner and not the validator -- Reverts if `currentAddress` does not exist -- Reverts if `newAddress` already exists in the validator set -- Updates the validator with the new address -- Updates lookup maps: removes old address entry, adds new address entry +## Invariants -```solidity -// V1 interface used during migration -interface IValidatorConfigV1 { - struct Validator { - bytes32 publicKey; - bool active; - uint64 index; - address validatorAddress; - string inboundAddress; - string outboundAddress; - } - - function getAllValidators() external view returns (Validator[] memory); - function getNextFullDkgCeremony() external view returns (uint64); - function owner() external view returns (address); -} -``` +### Identity and Uniqueness -### Properties +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. **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. -- **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 +### Lifecycle and Storage Behavior -## Timeline - -``` -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 - -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 - -**Important**: Complete migration before an epoch boundary to avoid disrupting DKG. - -### For New Networks - -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 - ---- - -## Test Cases - -The test suite must cover: - -### Basic Operations - -1. **Add validator**: Successfully adds a validator with valid signature -2. **Delete validator**: Successfully marks validator as deleted -3. **Change owner**: Successfully transfers ownership -4. **Set next DKG ceremony**: Successfully sets the epoch -5. **Rotate validator**: Successfully deletes old validator and adds new one atomically - -### Query Functions - -6. **getActiveValidators**: Returns only validators with `deactivatedAtHeight == 0` - (note: validators with `addedAtHeight == deactivatedAtHeight` are excluded) -7. **validatorByPublicKey**: Returns validator by public key lookup -8. **validatorCount**: Returns total count including deleted - -### Error Conditions - -9. **Unauthorized**: Non-owner cannot call protected functions -10. **ValidatorAlreadyExists**: Cannot re-add same address -11. **PublicKeyAlreadyExists**: Cannot re-use same public key -12. **ValidatorNotFound**: Cannot query/delete non-existent validator -13. **ValidatorAlreadyDeleted**: Cannot delete twice -14. **InvalidPublicKey**: Rejects zero public key -15. **InvalidSignature**: Rejects wrong signature, wrong length, wrong signer -16. **IngressAlreadyExists**: Cannot use ingress IP already in use by active validator (even with different port) - -### rotateValidator - -16. **rotateValidator by owner**: Owner can rotate any active validator -17. **rotateValidator by validator**: Validator can rotate themselves -18. **rotateValidator unauthorized**: Non-owner and non-validator cannot rotate -19. **rotateValidator already deleted**: Cannot rotate already-deleted validator -20. **rotateValidator new pubkey exists**: Cannot rotate to existing public key -21. **rotateValidator invalid signature**: Rejects invalid signature for rotation -22. **rotateValidator validator not found**: Reverts if validatorAddress does not exist -23. **rotateValidator atomicity**: Old validator is deleted and new one added in same block -24. **rotateValidator preserves index**: New validator gets next available index, old index remains deleted - -### Address Validation - -25. **Valid IPv4:port**: Accepts `192.168.1.1:8080` -26. **Valid IPv6:port**: Accepts `[2001:db8::1]:8080` -27. **Invalid format**: Rejects malformed addresses - -### Historical Filtering (Caller-side) - -28. **addedAtHeight correctness**: Validators have correct `addedAtHeight` set - at creation -29. **deactivatedAtHeight correctness**: Deleted validators have correct - `deactivatedAtHeight` set -30. **Filter logic**: Caller can correctly filter by - `addedAtHeight <= H && (deactivatedAtHeight == 0 || deactivatedAtHeight > H)` - -### Manual Migration - -31. **migrateValidator imports validator**: Calling `migrateValidator(i)` - correctly imports validator at index i from V1 with the same address -32. **migrateValidator copies address from V1**: The V2 validator uses the address from V1 -33. **migrateValidator reverts on duplicate**: Calling `migrateValidator(i)` reverts - if `i != validatorsArray.length` -34. **migrateValidator reverts if initialized**: Calling `migrateValidator` reverts - if `isInitialized() == true` -35. **migrateValidator owner only**: Non-owner cannot call `migrateValidator` -36. **All validators imported on migration**: Both V1 active and inactive validators - are imported; active ones have `addedAtHeight > 0` and `deactivatedAtHeight == 0`, - inactive ones have `addedAtHeight == deactivatedAtHeight > 0`. -37. **addedAtHeight set correctly**: All migrated validators have `addedAtHeight > 0` (block.height at migration time). -38. **deactivatedAtHeight set correctly**: Active validators have `deactivatedAtHeight == 0`. - Inactive validators have `addedAtHeight == deactivatedAtHeight > 0` at migration time. -39. **initialize sets flag**: After `initializeIfMigrated()`, `isInitialized()` returns true -40. **migrateValidator copies owner**: V2 `owner()` matches V1 after first `migrateValidator` call -41. **initialize copies DKG ceremony**: V2 `getNextFullDkgCeremony()` matches V1 - after `initializeIfMigrated()` -42. **initialize owner only**: Non-owner cannot call `initialize` -43. **isInitialized returns correct value**: Returns false before initialize, true after -44. **Writes blocked before init**: `addValidator`, `rotateValidator`, `transferValidatorOwnership` -45. **initialize reverts if not all migrated**: `initializeIfMigrated()` reverts if - `validatorsArray.length < V1.getAllValidators().length` - -### transferValidatorOwnership - -46. **transferValidatorOwnership by owner**: Owner can transfer any validator to a new address -47. **transferValidatorOwnership by validator**: Validator can transfer themselves to a new address -48. **transferValidatorOwnership unauthorized**: Non-owner and non-validator cannot transfer -49. **transferValidatorOwnership reverts on invalid validator**: Reverts if `validatorAddress` does not exist -50. **transferValidatorOwnership reverts on duplicate address**: Reverts if `newAddress` already exists -51. **transferValidatorOwnership updates lookup maps**: Old address is removed, new address is added to lookup - -### setIpAddresses - -52. **setIpAddresses by owner**: Owner can update any validator's IP addresses -53. **setIpAddresses by validator**: Validator can update their own IP addresses -54. **setIpAddresses unauthorized**: Non-owner and non-validator cannot update IP addresses -55. **setIpAddresses reverts on invalid validator**: Reverts if validator does not exist -56. **setIpAddresses reverts on deactivated validator**: Reverts if validator is already deactivated -57. **setIpAddresses validates format**: Rejects invalid `:` format -58. **setIpAddresses before init (self)**: Validator can update their own IPs before initialization -59. **setIpAddresses before init (owner)**: Owner can update validator IPs before initialization -60. **deactivateValidator before init (self)**: Validator can deactivate themselves before initialization -61. **deactivateValidator before init (owner)**: Owner can deactivate validators before initialization - ---- +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. -# Security Issues +### Query Correctness -## Migration Timing +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, 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`. -The migration must be completed (including `initializeIfMigrated()`) before an epoch -boundary to avoid disrupting DKG. The admin should: +### Migration and Initialization -1. Schedule migration during a period with no imminent epoch transitions -2. Monitor the current epoch and time remaining -3. Complete all `migrateValidator` calls and `initializeIfMigrated()` with sufficient time buffer +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.