From 13dd9854b27c844bda6abea5dc50f683fbc37670 Mon Sep 17 00:00:00 2001 From: maestroi Date: Sat, 13 Jan 2024 16:28:06 +0100 Subject: [PATCH] Initial commit --- .github/workflows/docker-image.yml | 96 ++++++++++++++ Dockerfile | 17 +++ README.md | 18 ++- docker-compose.yml | 19 +++ main.py | 193 +++++++++++++++++++++++++++++ requirements.txt | 2 + 6 files changed, 344 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/docker-image.yml create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 main.py create mode 100644 requirements.txt diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml new file mode 100644 index 0000000..37d5743 --- /dev/null +++ b/.github/workflows/docker-image.yml @@ -0,0 +1,96 @@ +name: Docker + +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +on: + push: + branches: [ "main" ] + # Publish semver tags as releases. + tags: [ 'v*.*.*' ] + pull_request: + branches: [ "main" ] + +env: + # Use docker.io for Docker Hub if empty + REGISTRY: ghcr.io + # github.repository as / + IMAGE_NAME: ${{ github.repository }} + + +jobs: + build: + + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + # This is used to complete the identity challenge + # with sigstore/fulcio when running outside of PRs. + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + # Install the cosign tool except on PR + # https://github.com/sigstore/cosign-installer + - name: Install cosign + if: github.event_name != 'pull_request' + uses: sigstore/cosign-installer@6e04d228eb30da1757ee4e1dd75a0ec73a653e06 #v3.1.1 + with: + cosign-release: 'v2.1.1' + + # Set up BuildKit Docker container builder to be able to build + # multi-platform images and export cache + # https://github.com/docker/setup-buildx-action + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0 + + # Login against a Docker registry except on PR + # https://github.com/docker/login-action + - name: Log into registry ${{ env.REGISTRY }} + if: github.event_name != 'pull_request' + uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Extract metadata (tags, labels) for Docker + # https://github.com/docker/metadata-action + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + + # Build and push Docker image with Buildx (don't push on PR) + # https://github.com/docker/build-push-action + - name: Build and push Docker image + id: build-and-push + uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0 + with: + context: . + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + # Sign the resulting Docker image digest except on PRs. + # This will only write to the public Rekor transparency log when the Docker + # repository is public to avoid leaking data. If you would like to publish + # transparency data even for private images, pass --force to cosign below. + # https://github.com/sigstore/cosign + - name: Sign the published Docker image + if: ${{ github.event_name != 'pull_request' }} + env: + # https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable + TAGS: ${{ steps.meta.outputs.tags }} + DIGEST: ${{ steps.build-and-push.outputs.digest }} + # This step uses the identity token to provision an ephemeral certificate + # against the sigstore community Fulcio instance. + run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..864ac89 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +# Use an Alpine image with Python 3 +FROM python:3-alpine + +# Set the working directory in the container +WORKDIR /usr/src/app + +# Copy the requirements file into the container +COPY requirements.txt ./ + +# Install dependencies from the requirements file +RUN pip install --no-cache-dir -r requirements.txt + +# Copy the Python script into the container +COPY main.py . + +# Command to run when the container starts +CMD ["python", "./main.py"] diff --git a/README.md b/README.md index 55393a5..bad0a2d 100644 --- a/README.md +++ b/README.md @@ -1 +1,17 @@ -# nimiq-validator-activator \ No newline at end of file +# nimiq-validator-activator + +Nimiq activator for validators, to be used with the Nimiq albatross Validators + +# Requirements + +- Docker +- Docker-compose +- Python3 + +# Expected + +We expect the following files to be present in the `./keys` folder: +with account keys and bls key for activating validator. +- A validator key file in the `./keys` folder +- A validator address file in the `./keys` folder +- A validator wallet file in the `./keys` folder diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..021a7a0 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,19 @@ +version: '3' + +services: + activate_validator: + container_name: activate-validator + build: + context: . + dockerfile: Dockerfile + environment: + - NIMIQ_NODE_URL=http://node:8648 + - NIMIQ_NETWORK=testnet + - FACUET_URL=https://faucet.pos.nimiq-testnet.com/tapit + - PROMETHEUS_PORT=8000 + volumes: + - "/opt/nimiq/validator/secrets:/keys" # mount your validator keys here + - "epoch-data:/usr/src/app" # mount epoch data will be stored here + ports: + - "8000:8000" + restart: unless-stopped diff --git a/main.py b/main.py new file mode 100644 index 0000000..c9ad039 --- /dev/null +++ b/main.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python3 +import os +import requests +import json +import time +import logging +from prometheus_client import start_http_server, Gauge + +NIMIQ_NODE_URL = os.getenv('NIMIQ_NODE_URL', 'http://node:8648') +NIMIQ_NETWORK = os.getenv('NIMIQ_NETWORK', 'testnet') +FACUET_URL = os.getenv('FACUET_URL','https://faucet.pos.nimiq-testnet.com/tapit') +PROMETHEUS_PORT = os.getenv('PROMETHEUS_PORT', 8000) + +# Prometheus Metrics +ACTIVATED_AMOUNT = Gauge('nimiq_activated_amount', 'Amount activated', ['address']) +ACTIVATE_EPOCH = Gauge('nimiq_activate_epoch', 'Epoch tried to activate validator') +EPOCH_NUMBER = Gauge('nimiq_epoch_number', 'Epoch number') + +logging.basicConfig(level=logging.DEBUG, + format='%(asctime)s — %(message)s', + datefmt='%Y-%m-%d_%H:%M:%S', + handlers=[logging.StreamHandler()]) + +# Store activation epoch in a file to not activate the validator multiple times in the same epoch. +def store_activation_epoch(epoch): + with open("activation_epoch.txt", "w") as file: + file.write(str(epoch)) + ACTIVATE_EPOCH.set(epoch) + +def read_activation_epoch(): + if os.path.exists("activation_epoch.txt"): + with open("activation_epoch.txt", "r") as file: + return int(file.read().strip()) + return None + +def nimiq_request(method, params=None, retries=3, delay=5): + while retries > 0: + try: + logging.debug(method) + logging.debug(params) + response = requests.post(NIMIQ_NODE_URL, json={ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params or [], + }) + response.raise_for_status() # Raises an HTTPError if the HTTP request returned an unsuccessful status code + logging.debug(response.json()) + result = response.json().get('result', {}) + time.sleep(0.5) # Wait for 0.5 second to not overload the node. + if result is None: + raise ValueError("No result in response") + return result + + except requests.exceptions.RequestException as err: + retries -= 1 + logging.error(f"Error: {err}. Retrying in {delay} seconds. Retries left: {retries}") + time.sleep(delay) + logging.error("Request failed after multiple retries.") + return None + +def get_private_key(file_path): + with open(file_path, 'r') as f: + lines = f.readlines() + for line in lines: + if 'Private Key:' in line: + return line.split('Private Key:')[1].strip() + return None + +def get_vote_key(file_path): + with open(file_path, 'r') as file: + lines = file.readlines() + + for i in range(len(lines)): + if "Secret Key:" in lines[i]: + secret_key = lines[i+2].strip() # The secret key is two lines down + + return secret_key + +def needs_funds(address): + res = nimiq_request("getAccountByAddress", [address]) + if res is None: + return False + data = res.get('data', {}) + if data is None or data.get('balance', 0) == 0: + return True + else: + return False + +def get_address(): + res = nimiq_request("getAddress") + if res is None: + return None + return res['data'] + +def get_tx(tx_hash): + res = nimiq_request("getTransactionByHash", [tx_hash]) + if res is None: + return None + if 'error' in res: + logging.error(f"Error getting transaction: {res['error']['message']}") + return None + logging.info(f"Transaction: {res}") + +def get_epoch_number(): + res = nimiq_request("getEpochNumber") + if res is None: + return None + EPOCH_NUMBER.set(res['data']) + +def activate_validator(): + ADDRESS = get_address() + logging.info(f"Address: {ADDRESS}") + + SIGKEY = get_private_key('/keys/address.txt') + + VOTEKEY = get_vote_key('/keys/vote_key.txt') + + ADDRESS_PRIVATE = get_private_key('/keys/address.txt') + + logging.info("Funding Nimiq address.") + if needs_funds(ADDRESS): + requests.post(FACUET_URL, data={'address': ADDRESS}) + else: + logging.info("Address already funded.") + + current_epoch = nimiq_request("getEpochNumber")['data'] + store_activation_epoch(current_epoch) + + logging.info("Importing private key.") + nimiq_request("importRawKey", [ADDRESS_PRIVATE, '']) + + logging.info("Unlock Account.") + nimiq_request("unlockAccount", [ADDRESS, '', 0]) + + logging.info("Activate Validator") + result = nimiq_request("sendNewValidatorTransaction", [ADDRESS, ADDRESS, SIGKEY, VOTEKEY, ADDRESS, "", 500, "+0"]) + + time.sleep(30) # Wait before checking the transaction + logging.info("Check Activate TX") + get_tx(result.get('data')) + + ACTIVATED_AMOUNT.labels(address=ADDRESS).inc() + return ADDRESS + +def is_validator_active(address): + res = nimiq_request("getActiveValidators") + if res is None: + return False + active_validators = res.get('data', []) + logging.info(json.dumps({"active_validators": active_validators})) + return address in active_validators + +def check_and_activate_validator(address): + current_epoch = nimiq_request("getEpochNumber")['data'] + activation_epoch = read_activation_epoch() + if activation_epoch is None or current_epoch > activation_epoch: + if not is_validator_active(address): + logging.info("Activating validator.") + activate_validator() + else: + logging.info("Validator already active.") + else: + next_epoch = activation_epoch + 1 + logging.info(f"Next epoch to activate validator: {next_epoch}") + logging.info("Waiting for next epoch to activate validator.") + +# Check if the consensus is establised and stay, if not, wait until it does. +def check_block_height(): + logging.info("Waiting for consensus to be established, this may take a while...") + consensus_count = 0 + while consensus_count < 3: + res = nimiq_request("isConsensusEstablished") + if res is not None and res.get('data') == True: + consensus_count += 1 + logging.info(f"Consensus established {consensus_count} time(s).") + else: + consensus_count = 0 + logging.info("Consensus not established yet.") + time.sleep(5) + logging.info("Consensus confirmed 3 times.") + +if __name__ == '__main__': + logging.info("Starting validator activation script...") + logging.info(f"Version: 0.1.0 ") + start_http_server(int(PROMETHEUS_PORT)) # Start Prometheus client + # Run indefinitely + while True: + check_block_height() + get_epoch_number() + address = get_address() + check_and_activate_validator(address) + time.sleep(600) # Wait for 10 minutes to check again. diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..cea8d88 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +requests +prometheus_client