diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..d51a736 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,39 @@ +name: tests + +on: + push: + branches: + - main + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.8", "3.10", "3.12"] + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements.txt + + - name: Compile Python sources + run: python -m compileall -q ValidatorExitHelper.py tests + + - name: Run unit tests + run: python -m unittest discover -s tests -v diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4201e34 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +.venv/ +venv/ +.pytest_cache/ +.mypy_cache/ +.coverage +htmlcov/ +exited_validators_log*.jsonl +exited_validators_log*.txt diff --git a/README.md b/README.md index 469f1dd..7a7d86f 100644 --- a/README.md +++ b/README.md @@ -1,117 +1,188 @@ - -# ValidatorExitHelper - -ValidatorExitHelper is a program that facilitates the **exit** of Ethereum Validators from the blockchain network. It supports selecting the number of Validators to exit or choosing all, ensuring a safe exit process with confirmation before execution of irreversible commands. The program also logs important information such as Public Key and URL for future verification. - ---- - -## Purpose -1. Facilitate the safe exit of multiple Validators at once. -2. Reduce human error by confirming actions before executing irreversible commands. -3. Log essential information like Public Key and URL for verifying Validator status after exiting. - ---- - -## Features -- Only supports exiting Validators with `enabled: true` status. -- Allows selecting a specific number of Validators to exit, or exit all Validators. -- Verifies the success of the `lighthouse account validator exit` command and retries if the command fails. -- Logs Public Key and URL to `exited_validators_log.txt`. -- Displays important warnings during the process. - ---- - -## Installation - -### 1. Install Dependencies -This program requires Python 3.7 or later and the `PyYAML` library: -```bash -sudo apt update -sudo apt install python3 python3-pip -y -pip install pyyaml -``` - -### 2. Download the Program Files -To download the program files, run the following command to clone the repository: -```bash -git clone https://github.com/AM-979/ValidatorExitHelper.git && cd ValidatorExitHelper -``` ---- - -## Configuration - -Before using **ValidatorExitHelper**, you will need to configure the following settings in the `ValidatorExitHelper.py` script: - -1. **File Path for YAML Configuration** - - The script expects a YAML configuration file containing the list of validators you want to exit. - - You need to define the correct path to this YAML file. - - To configure it, modify the line in `ValidatorExitHelper.py`: - ```python - file_path = "validator_definitions.yml" - ``` - - Replace `"validator_definitions.yml"` with the actual path to your YAML configuration file. - EX.PATH : /home/USER/.lighthouse/custom/validators/ - -3. **Set the Testnet Directory** - - The script also requires a `testnet_dir` for the configuration files. - - Modify the line in `ValidatorExitHelper.py`: - ```python - TESTNET_DIR = "/path/to/your/testnet/config" - ``` - - Replace `"/path/to/your/testnet/config"` with the actual path to your testnet configuration directory. - Download from here : https://github.com/jibchain-net/node - -Once you have updated these settings, you can proceed to use the script. - ---- - -## Usage - -### 1. Prepare `validator_definitions.yml` File -The `validator_definitions.yml` file must be in the same directory as the program and have the following structure: -```yaml -- enabled: true - voting_public_key: 0x... - voting_keystore_path: /path/to/keystore.json - voting_keystore_password: 'password' -``` - -### 2. Run the Program -To start the program, run the following command: -```bash -python3 ValidatorExitHelper.py -``` - -### 3. Choose Number of Validators -- Enter the number of Validators to exit. -- Confirm the action by typing **yes** or **no**. - -### 4. Check the Log File -After completion, the Public Key and URL of the exited Validators will be logged in `exited_validators_log.txt`: -```plaintext -Public Key: 0x123... -URL: https://dora.jibchain.net/validator/0x123... -``` - ---- - -## Caution -1. The `lighthouse account validator exit` command **cannot be undone**. -2. Keep your Lighthouse Validator running until all coins have been returned to your Wallet. - ---- - -## Donate 💖 -If you find this program helpful and would like to support it, donations are appreciated via: - -- **BTC Native SegWit**: `bc1qlpy59lmup27ylrffe7kg2sp9wj0zfka8q8j9dz` -- **ERC20/BEP20/JIBChain**: `0xba2eab518482c75789a262ce3e4ded6941c36370` - ---- - -**Developer** -AM979 | [xpool.pw](https://xpool.pw) +# ValidatorExitHelper + +ValidatorExitHelper submits voluntary exits for multiple Lighthouse validators while adding preflight validation, an explicit irreversible-operation confirmation, shell-free process execution, and an append-only audit log. + +The default endpoint and explorer target **Jibchain**, but the Lighthouse executable, validator definitions, custom-network directory, beacon node, explorer, and log path are configurable. + +> [!CAUTION] +> A validator voluntary exit is irreversible. Test the exact configuration with `--dry-run` first, verify every public key shown before confirmation, and keep each validator online until its exit epoch. + +## Safety properties + +- Processes only entries with `enabled: true` and `type: local_keystore`. +- Validates YAML structure, public keys, duplicate keys, keystore files, password files, Lighthouse, and the custom-network directory before submitting an exit. +- Rejects an exit count of `0`, negative/invalid input, and counts larger than the available validator set. +- Shows every selected public key, keystore path, and explorer URL before execution. +- Requires the exact phrase `EXIT N VALIDATOR(S)` before the irreversible operation. +- Calls Lighthouse with an argument list and `shell=False`; it never constructs a shell pipeline or places passwords in command arguments. +- Uses Lighthouse without `--no-wait`, so an exit is recorded as `confirmed` only after Lighthouse observes an exiting/exited validator state. +- Appends mode-`0600` JSON Lines records instead of overwriting previous history. +- Supports a non-destructive `--dry-run` mode. + +The script intentionally does **not** retry failed exits automatically. Before retrying, check the validator explorer and audit log to avoid submitting an operation whose prior state is uncertain. + +## Requirements + +- Linux or another operating system supported by Lighthouse +- Python 3.8 or later +- Lighthouse installed and executable +- A synchronized beacon-node HTTP API for the same network as the supplied custom-network configuration +- Local EIP-2335 voting keystores referenced by `validator_definitions.yml` + +## Installation + +```bash +git clone https://github.com/AM-979/ValidatorExitHelper.git +cd ValidatorExitHelper + +python3 -m venv .venv +source .venv/bin/activate +python -m pip install --upgrade pip +python -m pip install -r requirements.txt +``` + +## Lighthouse validator definitions + +The script reads Lighthouse's existing `validator_definitions.yml`. A current local-keystore entry normally resembles: + +```yaml +--- +- enabled: true + voting_public_key: "0x87a580d31d7bc69069b55f5a01995a610dd391a26dc9e36e81057a17211983a79266800ab8531f21f1083d7d84085007" + type: local_keystore + voting_keystore_path: /home/user/.lighthouse/custom/validators/0x87a5.../voting-keystore.json + voting_keystore_password_path: /home/user/.lighthouse/custom/secrets/0x87a5... +``` + +`voting_keystore_password_path` is preferred. If only `voting_keystore_password` is present, ValidatorExitHelper copies it to a temporary mode-`0600` file, passes that file to Lighthouse, and deletes it immediately afterward. If neither password field is present, Lighthouse inherits the terminal and prompts for the keystore password. + +Enabled `web3signer` entries are skipped because this command requires access to the local voting keystore. + +## Default configuration + +| Setting | Default | +|---|---| +| Validator definitions | `~/.lighthouse/custom/validators/validator_definitions.yml` | +| Jibchain custom-network directory | `~/node/config` | +| Lighthouse executable | `lighthouse` from `PATH` | +| Beacon node | `https://metrabyte-cl.jibchain.net/` | +| Explorer | `https://dora.jibchain.net/validator/` | +| Audit log | `./exited_validators_log.jsonl` | + +Every setting can be supplied as a command-line option or environment variable. + +## Recommended workflow + +### 1. Validate without exiting + +```bash +python3 ValidatorExitHelper.py --count all --dry-run +``` + +Review: + +- the number of validated validators; +- every selected public key; +- every keystore path; +- the Lighthouse commands displayed by the dry run; +- warnings about skipped validators or inline passwords. + +### 2. Exit a specific number + +```bash +python3 ValidatorExitHelper.py --count 2 +``` + +The script displays the exact validator list and requires: + +```text +EXIT 2 VALIDATORS +``` + +For one validator, the required phrase is `EXIT 1 VALIDATOR`. + +### 3. Exit all validated local validators + +```bash +python3 ValidatorExitHelper.py --count all +``` + +When `--count` is omitted, the script prompts for a positive integer or `all`. + +## Custom paths and endpoint + +```bash +python3 ValidatorExitHelper.py \ + --validator-definitions /home/jbc/.lighthouse/custom/validators/validator_definitions.yml \ + --testnet-dir /home/jbc/node/config \ + --lighthouse /usr/local/bin/lighthouse \ + --beacon-node https://metrabyte-cl.jibchain.net/ \ + --count 2 +``` + +Equivalent environment variables are: + +```bash +export VALIDATOR_DEFINITIONS_PATH=/home/jbc/.lighthouse/custom/validators/validator_definitions.yml +export TESTNET_DIR=/home/jbc/node/config +export LIGHTHOUSE_PATH=/usr/local/bin/lighthouse +export BEACON_NODE=https://metrabyte-cl.jibchain.net/ +export EXPLORER_BASE=https://dora.jibchain.net/validator/ +export EXIT_LOG_FILE=/secure/path/exited_validators_log.jsonl +``` + +## Options + +```text +--validator-definitions PATH Lighthouse validator_definitions.yml +--testnet-dir PATH Custom-network configuration directory +--lighthouse PATH Lighthouse executable name or path +--beacon-node URL Beacon Node HTTP API +--explorer-base URL Validator explorer base URL +--log-file PATH Append-only JSON Lines audit log +--count N|all Number of validators to exit +--dry-run Validate and display commands only +--stop-on-error Stop after the first Lighthouse failure +--delay SECONDS Delay between exits; default 1 second +``` + +## Audit log + +Each processed validator produces one JSON object per line: + +```json +{"beacon_node":"https://metrabyte-cl.jibchain.net/","explorer_url":"https://dora.jibchain.net/validator/0x...","keystore_path":"/home/user/.../voting-keystore.json","message":"Lighthouse confirmed the voluntary exit state.","public_key":"0x...","status":"confirmed","timestamp_utc":"2026-07-17T05:00:00+00:00"} +``` + +Possible statuses: + +- `confirmed`: Lighthouse returned success after observing an exiting/exited state. +- `failed`: Lighthouse or process execution failed. +- `dry-run`: no voluntary exit was submitted. +- `interrupted`: the user interrupted processing of that validator. + +The log is append-only and forced to mode `0600`. It contains keystore paths and validator public keys, so keep it private. + +## Withdrawal timing + +Do not assume funds will arrive within a fixed one- or two-day window. Timing depends on the network's exit queue, withdrawability delay, withdrawal credentials, and withdrawal sweep. The validator must remain online and continue performing duties until its exit epoch to avoid penalties. + +An exit does not by itself repair withdrawal credentials. Verify that the configured withdrawal address and credential type are correct before exiting. + +## Tests + +```bash +python -m unittest discover -s tests -v +python -m compileall -q ValidatorExitHelper.py tests +``` + +GitHub Actions runs the same checks on supported Python versions. + +## Donations + +- **BTC Native SegWit:** `bc1qlpy59lmup27ylrffe7kg2sp9wj0zfka8q8j9dz` +- **ERC20 / BEP20 / POL / Jibchain:** `0xba2eab518482c75789a262ce3e4ded6941c36370` + +## Developer + +AM979 — [xpool.pw](https://xpool.pw) diff --git a/ValidatorExitHelper.py b/ValidatorExitHelper.py index 4aa67ee..6424848 100644 --- a/ValidatorExitHelper.py +++ b/ValidatorExitHelper.py @@ -1,120 +1,573 @@ -#!/usr/bin/env python3 -import os -import time -import yaml -import subprocess -from getpass import getpass - -# āļāļģāļŦāļ™āļ”āļŠāļ·āđˆāļ­āđ„āļŸāļĨāđŒ YAML āđāļĨāļ°āļ•āļģāđāļŦāļ™āđˆāļ‡ testnet_dir -FILE_PATH = "/home/USER/.lighthouse/custom/validators/validator_definitions.yml" -TESTNET_DIR = "/home/USER/node/config/" # āđāļ—āļ™āļ—āļĩāđˆāļ”āđ‰āļ§āļĒāļžāļēāļ˜āļ—āļĩāđˆāļ•āđ‰āļ­āļ‡āļāļēāļĢ https://github.com/jibchain-net/node - -# āļāļģāļŦāļ™āļ” path āļ‚āļ­āļ‡ Lighthouse -LIGHTHOUSE_PATH = "/usr/local/bin/lighthouse" # āļŦāļĢāļ·āļ­āļāļģāļŦāļ™āļ” path āļ—āļĩāđˆāļ•āđ‰āļ­āļ‡āļāļēāļĢ - -# āļŸāļąāļ‡āļāđŒāļŠāļąāļ™āļ•āļĢāļ§āļˆāļŠāļ­āļšāļ§āđˆāļēāđ„āļŸāļĨāđŒ YAML āļĄāļĩāļ­āļĒāļđāđˆāļŦāļĢāļ·āļ­āđ„āļĄāđˆ -def check_file_exists(file_path): - if not os.path.exists(file_path): - print(f"Error: The file '{file_path}' does not exist.") - return False - return True - -# āļŸāļąāļ‡āļāđŒāļŠāļąāļ™āļ­āđˆāļēāļ™āđ„āļŸāļĨāđŒ YAML -def load_validators(file_path): - with open(file_path, "r") as file: - return yaml.safe_load(file) - -# āļŸāļąāļ‡āļāđŒāļŠāļąāļ™āļ–āļēāļĄāļĒāļ·āļ™āļĒāļąāļ™āļāļēāļĢāļ”āļģāđ€āļ™āļīāļ™āļāļēāļĢ -def confirm_action(message): - response = input(f"{message} (yes/no): ").strip().lower() - return response == "yes" - -# āļŸāļąāļ‡āļāđŒāļŠāļąāļ™āļšāļąāļ™āļ—āļķāļ log -def log_exited_validators(exited_validators): - with open("exited_validators_log.txt", "w") as file: - for public_key, url in exited_validators: - file.write(f"Public Key: {public_key}\n") - file.write(f"URL: {url}\n") - file.write("\n") - -# āļŸāļąāļ‡āļāđŒāļŠāļąāļ™āļˆāļąāļ”āļāļēāļĢāļāļēāļĢāļĢāđ‰āļ­āļ‡āļ‚āļ­āļĢāļŦāļąāļŠāļœāđˆāļēāļ™āļŠāļģāļŦāļĢāļąāļš sudo -def request_sudo_password(): - # āđƒāļŦāđ‰āļœāļđāđ‰āđƒāļŠāđ‰āļāļĢāļ­āļāļĢāļŦāļąāļŠāļœāđˆāļēāļ™ sudo - password = getpass(prompt="Enter your sudo password: ") - return password - -# āļŸāļąāļ‡āļāđŒāļŠāļąāļ™ exit validator -def exit_validators(validators, num_to_exit): - exited_validators = [] - counter = 0 - - for validator in validators: - if validator.get("enabled", False): - public_key = validator.get("voting_public_key") - keystore_path = validator.get("voting_keystore_path") - if not keystore_path: - continue - - print(f"Exiting validator: {public_key}") - - # āļ‚āļ­āļĢāļŦāļąāļŠāļœāđˆāļēāļ™āļˆāļēāļāļœāļđāđ‰āđƒāļŠāđ‰ - sudo_password = request_sudo_password() - - # āļŠāļĢāđ‰āļēāļ‡āļ„āļģāļŠāļąāđˆāļ‡ sudo - command = [ - "echo", sudo_password, "|", "sudo", "-S", LIGHTHOUSE_PATH, "account", "validator", "exit", - "--keystore", keystore_path, - "--beacon-node", "https://metrabyte-cl.jibchain.net/", - f"--testnet-dir={TESTNET_DIR}" - ] - - try: - # āļĢāļąāļ™āļ„āļģāļŠāļąāđˆāļ‡ - subprocess.run(command, check=True, shell=True) - url = f"https://dora.jibchain.net/validator/{public_key}" - exited_validators.append((public_key, url)) - print(f"Success: {public_key}") - time.sleep(1) - counter += 1 - except subprocess.CalledProcessError as e: - print(f"Error exiting validator {public_key}: {e}") - - # āļ•āļĢāļ§āļˆāļŠāļ­āļšāļˆāļģāļ™āļ§āļ™āļ—āļĩāđˆāļ­āļ­āļāđāļĨāđ‰āļ§ - if num_to_exit != "all" and counter >= int(num_to_exit): - break - - # āļšāļąāļ™āļ—āļķāļ log - log_exited_validators(exited_validators) - print("\n*** Exit process completed ***") - print("Please review the exited validators in 'exited_validators_log.txt'.") - print("Do not close your Lighthouse validator until all funds are returned to your wallet (1-2 days).") - -# āļŸāļąāļ‡āļāđŒāļŠāļąāļ™āļŦāļĨāļąāļ -def main(): - print("ValidatorExitHelper - Safely Exit Your Validators") - print("-------------------------------------------------\n") - - print("Donate to support further development:") - print("BTC (Native SegWit): bc1qlpy59lmup27ylrffe7kg2sp9wj0zfka8q8j9dz") - print("ERC20/BEP20/POL/JIBChain: 0xba2eab518482c75789a262ce3e4ded6941c36370\n") - - if not check_file_exists(FILE_PATH): - return - - validators = load_validators(FILE_PATH) - enabled_validators = [v for v in validators if v.get("enabled", False)] - print(f"Total enabled validators: {len(enabled_validators)}\n") - - num_to_exit = input("Enter the number of validators to exit or type 'all': ").strip() - if num_to_exit != "all" and not num_to_exit.isdigit(): - print("Invalid input. Please enter a valid number or 'all'.") - return - - if not confirm_action(f"Are you sure you want to exit {num_to_exit} validators? This action cannot be undone."): - print("Operation canceled.") - return - - exit_validators(enabled_validators, num_to_exit) - -if __name__ == "__main__": - main() +#!/usr/bin/env python3 +"""Safely submit Lighthouse voluntary exits for local validators. + +The script reads enabled local-keystore validators from Lighthouse's +``validator_definitions.yml``, validates all inputs before making changes, +requires an explicit irreversible-operation confirmation, and records an +append-only JSON Lines audit log. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import shlex +import shutil +import subprocess +import sys +import tempfile +import time +from contextlib import contextmanager +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Dict, Iterator, List, Optional, Sequence, Tuple + +import yaml + +DEFAULT_VALIDATOR_DEFINITIONS = ( + "~/.lighthouse/custom/validators/validator_definitions.yml" +) +DEFAULT_TESTNET_DIR = "~/node/config" +DEFAULT_LIGHTHOUSE = "lighthouse" +DEFAULT_BEACON_NODE = "https://metrabyte-cl.jibchain.net/" +DEFAULT_EXPLORER_BASE = "https://dora.jibchain.net/validator/" +DEFAULT_LOG_FILE = "exited_validators_log.jsonl" + +PUBLIC_KEY_PATTERN = re.compile(r"^0x[0-9a-fA-F]{96}$") + + +class ValidatorExitError(Exception): + """Raised for configuration or validation errors that prevent safe execution.""" + + +@dataclass(frozen=True) +class Validator: + """Validated local validator definition used to construct an exit command.""" + + public_key: str + keystore_path: Path + password_path: Optional[Path] + inline_password: Optional[str] + + +def expand_path(value: str, base_dir: Optional[Path] = None) -> Path: + """Expand environment variables and ``~``, resolving relative paths safely.""" + + expanded = Path(os.path.expandvars(os.path.expanduser(value))) + if not expanded.is_absolute() and base_dir is not None: + expanded = base_dir / expanded + return expanded.resolve() + + +def load_validators(file_path: Path) -> Tuple[List[Validator], List[str]]: + """Load and validate enabled local-keystore validators from Lighthouse YAML.""" + + if not file_path.is_file(): + raise ValidatorExitError( + "Validator definitions file does not exist or is not a file: " + f"{file_path}" + ) + + try: + with file_path.open("r", encoding="utf-8") as handle: + raw_data = yaml.safe_load(handle) + except yaml.YAMLError as exc: + raise ValidatorExitError( + f"Unable to parse validator definitions YAML: {exc}" + ) from exc + except OSError as exc: + raise ValidatorExitError( + f"Unable to read validator definitions file: {exc}" + ) from exc + + if raw_data is None: + raise ValidatorExitError("Validator definitions file is empty.") + if not isinstance(raw_data, list): + raise ValidatorExitError( + "Validator definitions YAML must contain a top-level list." + ) + + validators: List[Validator] = [] + warnings: List[str] = [] + public_keys = set() + base_dir = file_path.parent + + for index, entry in enumerate(raw_data, start=1): + location = f"entry {index}" + if not isinstance(entry, dict): + raise ValidatorExitError(f"{location} must be a YAML mapping.") + if entry.get("enabled") is not True: + continue + + validator_type = entry.get("type", "local_keystore") + if validator_type != "local_keystore": + warnings.append( + f"Skipping {location}: enabled validator type '{validator_type}' " + "does not expose a local keystore." + ) + continue + + public_key = entry.get("voting_public_key") + if not isinstance(public_key, str) or not PUBLIC_KEY_PATTERN.fullmatch( + public_key + ): + raise ValidatorExitError( + f"{location} has an invalid voting_public_key; expected 0x plus " + "96 hexadecimal characters." + ) + public_key = public_key.lower() + if public_key in public_keys: + raise ValidatorExitError( + f"Duplicate voting_public_key in enabled validators: {public_key}" + ) + + raw_keystore_path = entry.get("voting_keystore_path") + if not isinstance(raw_keystore_path, str) or not raw_keystore_path.strip(): + raise ValidatorExitError( + f"{location} is missing voting_keystore_path." + ) + keystore_path = expand_path(raw_keystore_path, base_dir) + if not keystore_path.is_file(): + raise ValidatorExitError( + f"Keystore for {public_key} does not exist or is not a file: " + f"{keystore_path}" + ) + + password_path: Optional[Path] = None + raw_password_path = entry.get("voting_keystore_password_path") + if raw_password_path is not None: + if not isinstance(raw_password_path, str) or not raw_password_path.strip(): + raise ValidatorExitError( + f"{location} has an invalid voting_keystore_password_path." + ) + password_path = expand_path(raw_password_path, base_dir) + if not password_path.is_file(): + raise ValidatorExitError( + f"Password file for {public_key} does not exist or is not a file: " + f"{password_path}" + ) + + inline_password = entry.get("voting_keystore_password") + if inline_password is not None and not isinstance(inline_password, str): + raise ValidatorExitError( + f"{location} has a non-string voting_keystore_password." + ) + if password_path is not None and inline_password is not None: + warnings.append( + f"{public_key}: both password path and inline password are present; " + "the password path will be used." + ) + inline_password = None + elif inline_password is not None: + warnings.append( + f"{public_key}: inline keystore password detected. It will be copied " + "to a temporary mode-0600 file for Lighthouse and deleted immediately." + ) + + validators.append( + Validator( + public_key=public_key, + keystore_path=keystore_path, + password_path=password_path, + inline_password=inline_password, + ) + ) + public_keys.add(public_key) + + if not validators: + raise ValidatorExitError( + "No enabled local-keystore validators were found after validation." + ) + + return validators, warnings + + +def resolve_lighthouse(executable: str) -> Path: + """Resolve and validate the Lighthouse executable without invoking a shell.""" + + expanded = os.path.expandvars(os.path.expanduser(executable)) + if os.sep in expanded: + candidate = Path(expanded).resolve() + else: + located = shutil.which(expanded) + if located is None: + raise ValidatorExitError( + f"Lighthouse executable was not found in PATH: {executable}" + ) + candidate = Path(located).resolve() + + if not candidate.is_file(): + raise ValidatorExitError( + f"Lighthouse executable does not exist or is not a file: {candidate}" + ) + if not os.access(str(candidate), os.X_OK): + raise ValidatorExitError( + f"Lighthouse executable is not executable: {candidate}" + ) + return candidate + + +def validate_testnet_dir(testnet_dir: Path) -> None: + """Verify that the custom network directory is present and non-empty.""" + + if not testnet_dir.is_dir(): + raise ValidatorExitError( + f"Testnet directory does not exist or is not a directory: {testnet_dir}" + ) + try: + if not any(testnet_dir.iterdir()): + raise ValidatorExitError(f"Testnet directory is empty: {testnet_dir}") + except OSError as exc: + raise ValidatorExitError(f"Unable to inspect testnet directory: {exc}") from exc + + +def parse_exit_count(value: str, available: int) -> int: + """Parse ``all`` or a positive validator count within the available range.""" + + normalized = value.strip().lower() + if normalized == "all": + return available + if not normalized.isdigit(): + raise ValidatorExitError("Exit count must be a positive integer or 'all'.") + + count = int(normalized) + if count < 1: + raise ValidatorExitError("Exit count must be at least 1.") + if count > available: + raise ValidatorExitError( + f"Requested {count} validators, but only {available} are available." + ) + return count + + +def build_exit_command( + lighthouse: Path, + testnet_dir: Path, + beacon_node: str, + validator: Validator, + password_file: Optional[Path], +) -> List[str]: + """Build a shell-free Lighthouse command after the script's strong confirmation.""" + + command = [ + str(lighthouse), + "--testnet-dir", + str(testnet_dir), + "account", + "validator", + "exit", + "--keystore", + str(validator.keystore_path), + "--beacon-node", + beacon_node, + "--no-confirmation", + ] + if password_file is not None: + command.extend(["--password-file", str(password_file)]) + return command + + +@contextmanager +def lighthouse_password_file(validator: Validator) -> Iterator[Optional[Path]]: + """Yield a password file without exposing password contents in process arguments.""" + + if validator.password_path is not None: + yield validator.password_path + return + if validator.inline_password is None: + yield None + return + + file_descriptor, temporary_name = tempfile.mkstemp( + prefix=".validator-exit-password-", text=True + ) + temporary_path = Path(temporary_name) + try: + os.fchmod(file_descriptor, 0o600) + with os.fdopen(file_descriptor, "w", encoding="utf-8") as handle: + handle.write(validator.inline_password) + handle.write("\n") + yield temporary_path + finally: + try: + temporary_path.unlink() + except FileNotFoundError: + pass + + +def append_audit_log(log_file: Path, record: Dict[str, object]) -> None: + """Append one mode-0600 JSON record without truncating prior audit history.""" + + log_file.parent.mkdir(parents=True, exist_ok=True) + flags = os.O_APPEND | os.O_CREAT | os.O_WRONLY + file_descriptor = os.open(str(log_file), flags, 0o600) + try: + os.fchmod(file_descriptor, 0o600) + payload = json.dumps(record, ensure_ascii=False, sort_keys=True) + "\n" + os.write(file_descriptor, payload.encode("utf-8")) + finally: + os.close(file_descriptor) + + +def make_audit_record( + validator: Validator, + status: str, + beacon_node: str, + explorer_base: str, + message: str = "", +) -> Dict[str, object]: + """Create an audit record that contains no password material.""" + + return { + "timestamp_utc": datetime.now(timezone.utc).isoformat(), + "public_key": validator.public_key, + "status": status, + "beacon_node": beacon_node, + "keystore_path": str(validator.keystore_path), + "explorer_url": f"{explorer_base.rstrip('/')}/{validator.public_key}", + "message": message, + } + + +def print_selection(validators: Sequence[Validator], explorer_base: str) -> None: + """Display exact validators before the irreversible confirmation prompt.""" + + print("\nValidators selected for voluntary exit:") + for position, validator in enumerate(validators, start=1): + print(f" {position}. {validator.public_key}") + print(f" Keystore: {validator.keystore_path}") + print( + " Explorer: " + f"{explorer_base.rstrip('/')}/{validator.public_key}" + ) + + +def confirm_irreversible_action(count: int) -> bool: + """Require an exact phrase instead of a weak yes/no confirmation.""" + + noun = "VALIDATOR" if count == 1 else "VALIDATORS" + phrase = f"EXIT {count} {noun}" + print("\nWARNING: A voluntary exit is irreversible.") + response = input(f"Type '{phrase}' to continue: ").strip() + return response == phrase + + +def submit_validator_exit( + lighthouse: Path, + testnet_dir: Path, + beacon_node: str, + validator: Validator, + dry_run: bool, +) -> Tuple[str, str]: + """Submit one exit and return an audit status and human-readable message.""" + + with lighthouse_password_file(validator) as password_file: + command = build_exit_command( + lighthouse=lighthouse, + testnet_dir=testnet_dir, + beacon_node=beacon_node, + validator=validator, + password_file=password_file, + ) + if dry_run: + return "dry-run", shlex.join(command) + + try: + # shell=False is intentional. Lighthouse inherits the terminal only when + # it needs to prompt for a password that was not supplied by the YAML. + subprocess.run(command, check=True) + except subprocess.CalledProcessError as exc: + return "failed", f"Lighthouse exited with status {exc.returncode}." + except OSError as exc: + return "failed", f"Unable to execute Lighthouse: {exc}" + + # Lighthouse is intentionally run without --no-wait. A zero exit status is + # recorded only after it observes the validator entering an exiting/exited state. + return "confirmed", "Lighthouse confirmed the voluntary exit state." + + +def create_argument_parser() -> argparse.ArgumentParser: + """Create the command-line interface.""" + + parser = argparse.ArgumentParser( + description=( + "Safely exit enabled local-keystore validators using Lighthouse. " + "Defaults target Jibchain but every path and endpoint is configurable." + ) + ) + parser.add_argument( + "--validator-definitions", + default=os.environ.get( + "VALIDATOR_DEFINITIONS_PATH", DEFAULT_VALIDATOR_DEFINITIONS + ), + help="Path to Lighthouse validator_definitions.yml.", + ) + parser.add_argument( + "--testnet-dir", + default=os.environ.get("TESTNET_DIR", DEFAULT_TESTNET_DIR), + help="Path to the Lighthouse custom-network configuration directory.", + ) + parser.add_argument( + "--lighthouse", + default=os.environ.get("LIGHTHOUSE_PATH", DEFAULT_LIGHTHOUSE), + help="Lighthouse executable name or absolute path.", + ) + parser.add_argument( + "--beacon-node", + default=os.environ.get("BEACON_NODE", DEFAULT_BEACON_NODE), + help="Beacon Node HTTP API endpoint used to publish and verify exits.", + ) + parser.add_argument( + "--explorer-base", + default=os.environ.get("EXPLORER_BASE", DEFAULT_EXPLORER_BASE), + help="Base validator explorer URL used in console output and logs.", + ) + parser.add_argument( + "--log-file", + default=os.environ.get("EXIT_LOG_FILE", DEFAULT_LOG_FILE), + help="Append-only JSON Lines audit log path.", + ) + parser.add_argument( + "--count", + help="Number of validators to exit, or 'all'. Prompts when omitted.", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Validate and display commands without submitting any exit.", + ) + parser.add_argument( + "--stop-on-error", + action="store_true", + help="Stop after the first Lighthouse failure instead of continuing.", + ) + parser.add_argument( + "--delay", + type=float, + default=1.0, + help="Seconds to wait between validator exit commands (default: 1).", + ) + return parser + + +def run(argv: Optional[Sequence[str]] = None) -> int: + """Run the CLI and return a process exit code.""" + + arguments = create_argument_parser().parse_args(argv) + if arguments.delay < 0: + raise ValidatorExitError("--delay cannot be negative.") + if not arguments.beacon_node.startswith(("http://", "https://")): + raise ValidatorExitError("--beacon-node must start with http:// or https://.") + + validator_definitions = expand_path(arguments.validator_definitions) + testnet_dir = expand_path(arguments.testnet_dir) + log_file = expand_path(arguments.log_file) + lighthouse = resolve_lighthouse(arguments.lighthouse) + validate_testnet_dir(testnet_dir) + + validators, warnings = load_validators(validator_definitions) + for warning in warnings: + print(f"Warning: {warning}", file=sys.stderr) + + print(f"Validated enabled local validators: {len(validators)}") + raw_count = arguments.count + if raw_count is None: + raw_count = input("Enter the number of validators to exit or 'all': ") + exit_count = parse_exit_count(raw_count, len(validators)) + selected = validators[:exit_count] + print_selection(selected, arguments.explorer_base) + + if arguments.dry_run: + print("\nDry-run mode: no voluntary exits will be submitted.") + elif not confirm_irreversible_action(exit_count): + print("Confirmation did not match. Operation canceled.") + return 2 + + confirmed = 0 + failed = 0 + dry_runs = 0 + + for position, validator in enumerate(selected, start=1): + print( + f"\n[{position}/{exit_count}] Processing validator " + f"{validator.public_key}" + ) + try: + status, message = submit_validator_exit( + lighthouse=lighthouse, + testnet_dir=testnet_dir, + beacon_node=arguments.beacon_node, + validator=validator, + dry_run=arguments.dry_run, + ) + except KeyboardInterrupt: + append_audit_log( + log_file, + make_audit_record( + validator, + "interrupted", + arguments.beacon_node, + arguments.explorer_base, + "Interrupted by user.", + ), + ) + raise + + append_audit_log( + log_file, + make_audit_record( + validator, + status, + arguments.beacon_node, + arguments.explorer_base, + message, + ), + ) + + if status == "confirmed": + confirmed += 1 + print(f"Confirmed: {validator.public_key}") + elif status == "dry-run": + dry_runs += 1 + print(f"Dry run command: {message}") + else: + failed += 1 + print(f"Failed: {validator.public_key}: {message}", file=sys.stderr) + if arguments.stop_on_error: + break + + if position < exit_count and not arguments.dry_run and arguments.delay: + time.sleep(arguments.delay) + + print("\nExit process summary") + print(f" Confirmed exits: {confirmed}") + print(f" Failed exits: {failed}") + print(f" Dry-run entries: {dry_runs}") + print(f" Audit log: {log_file}") + print( + "Keep each validator client online until its exit epoch. Withdrawal timing " + "depends on the chain's exit queue, withdrawability delay, credentials, and " + "withdrawal sweep; it must not be assumed to be 1-2 days." + ) + + return 1 if failed else 0 + + +def main() -> None: + """Console entry point with concise safe error handling.""" + + try: + raise SystemExit(run()) + except ValidatorExitError as exc: + print(f"Error: {exc}", file=sys.stderr) + raise SystemExit(2) + except KeyboardInterrupt: + print("\nInterrupted. Check the audit log and explorer before retrying.", file=sys.stderr) + raise SystemExit(130) + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9034813 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +PyYAML>=6.0,<7.0 diff --git a/tests/test_validator_exit_helper.py b/tests/test_validator_exit_helper.py new file mode 100644 index 0000000..8b22fba --- /dev/null +++ b/tests/test_validator_exit_helper.py @@ -0,0 +1,249 @@ +import json +import os +import stat +import subprocess +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +import yaml + +import ValidatorExitHelper as helper + + +PUBLIC_KEY = "0x" + ("a" * 96) +SECOND_PUBLIC_KEY = "0x" + ("b" * 96) + + +class ValidatorExitHelperTests(unittest.TestCase): + def create_validator_tree(self, directory: Path): + keys = directory / "keys" + secrets = directory / "secrets" + keys.mkdir() + secrets.mkdir() + keystore = keys / "voting-keystore.json" + password = secrets / "password.txt" + keystore.write_text("{}", encoding="utf-8") + password.write_text("secret\n", encoding="utf-8") + return keystore, password + + def test_load_validators_validates_and_resolves_relative_paths(self): + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + keystore, password = self.create_validator_tree(root) + definitions = root / "validator_definitions.yml" + definitions.write_text( + yaml.safe_dump( + [ + { + "enabled": True, + "voting_public_key": PUBLIC_KEY.upper().replace("0X", "0x"), + "type": "local_keystore", + "voting_keystore_path": "keys/voting-keystore.json", + "voting_keystore_password_path": "secrets/password.txt", + }, + { + "enabled": False, + "voting_public_key": SECOND_PUBLIC_KEY, + "type": "local_keystore", + "voting_keystore_path": "missing.json", + }, + ] + ), + encoding="utf-8", + ) + + validators, warnings = helper.load_validators(definitions) + + self.assertEqual(len(validators), 1) + self.assertEqual(validators[0].public_key, PUBLIC_KEY) + self.assertEqual(validators[0].keystore_path, keystore.resolve()) + self.assertEqual(validators[0].password_path, password.resolve()) + self.assertEqual(warnings, []) + + def test_load_validators_skips_enabled_web3signer(self): + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + keystore, _ = self.create_validator_tree(root) + definitions = root / "validator_definitions.yml" + definitions.write_text( + yaml.safe_dump( + [ + { + "enabled": True, + "voting_public_key": SECOND_PUBLIC_KEY, + "type": "web3signer", + "url": "http://127.0.0.1:9000", + }, + { + "enabled": True, + "voting_public_key": PUBLIC_KEY, + "type": "local_keystore", + "voting_keystore_path": str(keystore), + }, + ] + ), + encoding="utf-8", + ) + + validators, warnings = helper.load_validators(definitions) + + self.assertEqual([validator.public_key for validator in validators], [PUBLIC_KEY]) + self.assertEqual(len(warnings), 1) + self.assertIn("web3signer", warnings[0]) + + def test_load_validators_rejects_empty_yaml(self): + with tempfile.TemporaryDirectory() as temporary_directory: + definitions = Path(temporary_directory) / "validator_definitions.yml" + definitions.write_text("", encoding="utf-8") + + with self.assertRaisesRegex(helper.ValidatorExitError, "empty"): + helper.load_validators(definitions) + + def test_parse_exit_count_rejects_zero_and_out_of_range(self): + with self.assertRaisesRegex(helper.ValidatorExitError, "at least 1"): + helper.parse_exit_count("0", 2) + with self.assertRaisesRegex(helper.ValidatorExitError, "only 2"): + helper.parse_exit_count("3", 2) + self.assertEqual(helper.parse_exit_count("all", 2), 2) + self.assertEqual(helper.parse_exit_count("1", 2), 1) + + def test_build_command_contains_no_shell_pipeline_or_sudo(self): + validator = helper.Validator( + public_key=PUBLIC_KEY, + keystore_path=Path("/tmp/keystore.json"), + password_path=Path("/tmp/password.txt"), + inline_password=None, + ) + + command = helper.build_exit_command( + lighthouse=Path("/usr/local/bin/lighthouse"), + testnet_dir=Path("/home/user/node/config"), + beacon_node="https://example.invalid/", + validator=validator, + password_file=validator.password_path, + ) + + self.assertEqual(command[0], "/usr/local/bin/lighthouse") + self.assertNotIn("echo", command) + self.assertNotIn("sudo", command) + self.assertNotIn("|", command) + self.assertIn("--no-confirmation", command) + self.assertIn("--password-file", command) + + def test_submit_exit_calls_subprocess_without_shell_true(self): + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + keystore = root / "keystore.json" + password = root / "password.txt" + keystore.write_text("{}", encoding="utf-8") + password.write_text("secret\n", encoding="utf-8") + validator = helper.Validator( + public_key=PUBLIC_KEY, + keystore_path=keystore, + password_path=password, + inline_password=None, + ) + + with mock.patch.object(helper.subprocess, "run") as run_mock: + status, message = helper.submit_validator_exit( + lighthouse=Path("/usr/local/bin/lighthouse"), + testnet_dir=root, + beacon_node="https://example.invalid/", + validator=validator, + dry_run=False, + ) + + self.assertEqual(status, "confirmed") + self.assertIn("confirmed", message.lower()) + run_mock.assert_called_once() + _, keyword_arguments = run_mock.call_args + self.assertEqual(keyword_arguments, {"check": True}) + + def test_submit_exit_reports_nonzero_lighthouse_status(self): + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + keystore = root / "keystore.json" + keystore.write_text("{}", encoding="utf-8") + validator = helper.Validator( + public_key=PUBLIC_KEY, + keystore_path=keystore, + password_path=None, + inline_password=None, + ) + + with mock.patch.object( + helper.subprocess, + "run", + side_effect=subprocess.CalledProcessError(7, ["lighthouse"]), + ): + status, message = helper.submit_validator_exit( + lighthouse=Path("/usr/local/bin/lighthouse"), + testnet_dir=root, + beacon_node="https://example.invalid/", + validator=validator, + dry_run=False, + ) + + self.assertEqual(status, "failed") + self.assertIn("7", message) + + def test_inline_password_uses_temporary_mode_0600_file_and_removes_it(self): + validator = helper.Validator( + public_key=PUBLIC_KEY, + keystore_path=Path("/tmp/keystore.json"), + password_path=None, + inline_password="top-secret", + ) + + with helper.lighthouse_password_file(validator) as password_file: + self.assertIsNotNone(password_file) + assert password_file is not None + self.assertTrue(password_file.exists()) + self.assertEqual(password_file.read_text(encoding="utf-8"), "top-secret\n") + permissions = stat.S_IMODE(password_file.stat().st_mode) + self.assertEqual(permissions, 0o600) + temporary_path = password_file + + self.assertFalse(temporary_path.exists()) + + def test_dry_run_does_not_call_subprocess(self): + validator = helper.Validator( + public_key=PUBLIC_KEY, + keystore_path=Path("/tmp/keystore.json"), + password_path=None, + inline_password=None, + ) + + with mock.patch.object(helper.subprocess, "run") as run_mock: + status, command = helper.submit_validator_exit( + lighthouse=Path("/usr/local/bin/lighthouse"), + testnet_dir=Path("/tmp/config"), + beacon_node="https://example.invalid/", + validator=validator, + dry_run=True, + ) + + self.assertEqual(status, "dry-run") + self.assertIn("lighthouse", command) + run_mock.assert_not_called() + + def test_audit_log_appends_and_forces_private_permissions(self): + with tempfile.TemporaryDirectory() as temporary_directory: + log_file = Path(temporary_directory) / "audit.jsonl" + helper.append_audit_log(log_file, {"status": "dry-run", "number": 1}) + helper.append_audit_log(log_file, {"status": "confirmed", "number": 2}) + + records = [ + json.loads(line) + for line in log_file.read_text(encoding="utf-8").splitlines() + ] + self.assertEqual(len(records), 2) + self.assertEqual(records[0]["number"], 1) + self.assertEqual(records[1]["number"], 2) + self.assertEqual(stat.S_IMODE(log_file.stat().st_mode), 0o600) + + +if __name__ == "__main__": + unittest.main()