rm tools/keymaster (#461)

pull/466/head
Trevor Porter 3 years ago committed by GitHub
parent 2a96c607f5
commit 5ab8e221db
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 15
      tools/keymaster/Dockerfile
  2. 104
      tools/keymaster/README.md
  3. 0
      tools/keymaster/__init__.py
  4. 1
      tools/keymaster/build.sh
  5. 28
      tools/keymaster/config.py
  6. 23
      tools/keymaster/helm/keymaster/.helmignore
  7. 24
      tools/keymaster/helm/keymaster/Chart.yaml
  8. 62
      tools/keymaster/helm/keymaster/templates/_helpers.tpl
  9. 7
      tools/keymaster/helm/keymaster/templates/configmap.yaml
  10. 40
      tools/keymaster/helm/keymaster/templates/deployment.yaml
  11. 31
      tools/keymaster/helm/keymaster/values.yaml
  12. 42
      tools/keymaster/keymaster-example.json
  13. 206
      tools/keymaster/keymaster.py
  14. 1
      tools/keymaster/release.sh
  15. 6
      tools/keymaster/requirements.txt
  16. 81
      tools/keymaster/utils.py

@ -1,15 +0,0 @@
FROM python:3.9-bullseye
WORKDIR /code
# Allows docker to cache installed dependencies between builds
COPY ./requirements.txt requirements.txt
RUN pip install -r requirements.txt
COPY keymaster.py keymaster.py
COPY __init__.py __init__.py
COPY config.py config.py
COPY utils.py utils.py
RUN adduser --disabled-password --gecos '' unpriv
RUN chown -R unpriv: /code
CMD python3 keymaster.py monitor

@ -1,104 +0,0 @@
# The Keymaster
[*I am Vinz, Vinz Clortho, Keymaster of Gozer...Volguus Zildrohoar, Lord of the Seboullia. Are you the Gatekeeper?*](https://www.youtube.com/watch?v=xSp5QwKRwqM)
![Keymaster from Ghostbusters](https://i.pinimg.com/originals/9d/5b/a0/9d5ba02875ce7921d092038d1543b1f4.jpg)
## Summary
The Keymaster is a tool that is used to manage funds for Optics Agent Wallets. Due to the sheer number of networks Optics supports, and the necessity for having a unique set of keys for each home, managing funds and ensuring agents can continue to function quickly becomes difficult as the network of Optics channels grows.
Example:
For 4 homes (alfajores, kovan, rinkeby, rinkarby) with 5 addresses each (kathy, watcher, updater, processor, relayer), this means there will be 20 unique addresses and each address has to be funded on each network resulting in 20 * 4 = 80 unique accounts across all networks which must be funded and topped up regularly.
Generalized: num_homes^2 * num_addresses
The Keymaster stores metadata about addresses, sources of funds, network RPC endpoints, and more to facilitate solving this problem.
## Using The Keymaster
Note: Before you do *anything*, [call the Ghostbusters](https://www.youtube.com/watch?v=Fe93CLbHjxQ).
The Keymaster is a simple Python-based CLI program, the entrypoint is `keymaster.py`
Install the requirements via pip:
`pip3 install -r requirements.txt`
The Keymaster can be invoked via `python3` like so:
```
$ python3 keymaster.py --help
Usage: keymaster.py [OPTIONS] COMMAND [ARGS]...
Options:
--debug / --no-debug
--config-path TEXT
--help Show this message and exit.
Commands:
top-up
```
Subcommands can be invoked by passing them as arguments to the CLI:
```
$ python3 keymaster.py top-up --help
Usage: keymaster.py top-up [OPTIONS]
Options:
--help Show this message and exit.
```
## Configuration File
The Keymaster relies on a JSON configuration file, by default located at `./keymaster.json`. You can pass a new path to the file using the `--config-path` argument.
An example can be found at `./keymaster-example.json` and its contents are repeated here for convenience:
```
{
"networks": {
"alfajores": {
"endpoint": "https://alfajores-forno.celo-testnet.org",
"bank": {
"signer": "<hexKey>",
"address": "<address>"
},
"threshold": 500000000000000000
},
"kovan": {
"endpoint": "<RPCEndpoint>",
"bank": {
"signer": "<hexKey>",
"address": "<address>"
},
"threshold": 500000000000000000
}
},
"homes": {
"alfajores": {
"replicas": ["kovan"],
"addresses": {
"kathy": "<address>",
"watcher": "<address>",
"updater": "<address>",
"relayer": "<address>",
"processor": "<address>"
}
},
"kovan": {
"replicas": ["alfajores"],
"addresses": {
"kathy": "<address>",
"watcher": "<address>",
"updater": "<address>",
"relayer": "<address>",
"processor": "<address>"
}
}
}
}
```
In the `top-up` command, The Keymaster will load the contents of this file and use it to dynamically query the configured accounts and determine if they need to be topped up.

@ -1 +0,0 @@
docker build -t gcr.io/clabs-optics/keymaster:$1 .

@ -1,28 +0,0 @@
from python_json_config import ConfigBuilder
import json
def load_config(path: str):
# create config parser
builder = ConfigBuilder()
# parse config
try:
config = builder.parse_config(path)
config.merge_with_env_variables(["KEYMASTER"])
# TODO: Validate config
# networks.name.rpc must be a uri
# networks.name.bank must be a hexkey
# networks.name.threshold must be in gwei
# homes.name must be present in networks
# homes.name.replicas must be present in networks
# homes.name.addresses must be unique (?)
return config.to_dict()
except json.decoder.JSONDecodeError:
# Failed to load config
return False
# merge config with environment variables

@ -1,23 +0,0 @@
# Patterns to ignore when building packages.
# This supports shell glob matching, relative path matching, and
# negation (prefixed with !). Only one pattern per line.
.DS_Store
# Common VCS dirs
.git/
.gitignore
.bzr/
.bzrignore
.hg/
.hgignore
.svn/
# Common backup files
*.swp
*.bak
*.tmp
*.orig
*~
# Various IDEs
.project
.idea/
*.tmproj
.vscode/

@ -1,24 +0,0 @@
apiVersion: v2
name: keymaster
description: A tool that queries metrics about Ethereum accounts
# A chart can be either an 'application' or a 'library' chart.
#
# Application charts are a collection of templates that can be packaged into versioned archives
# to be deployed.
#
# Library charts provide useful utilities or functions for the chart developer. They're included as
# a dependency of application charts to inject those utilities and functions into the rendering
# pipeline. Library charts do not define any templates and therefore cannot be deployed.
type: application
# This is the chart version. This version number should be incremented each time you make changes
# to the chart and its templates, including the app version.
# Versions are expected to follow Semantic Versioning (https://semver.org/)
version: 0.1.0
# This is the version number of the application being deployed. This version number should be
# incremented each time you make changes to the application. Versions are not expected to
# follow Semantic Versioning. They should reflect the version the application is using.
# It is recommended to use it with quotes.
appVersion: "1.16.0"

@ -1,62 +0,0 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "keymaster.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
If release name contains chart name it will be used as a full name.
*/}}
{{- define "keymaster.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{/*
Create chart name and version as used by the chart label.
*/}}
{{- define "keymaster.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels
*/}}
{{- define "keymaster.labels" -}}
helm.sh/chart: {{ include "keymaster.chart" . }}
{{ include "keymaster.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{/*
Selector labels
*/}}
{{- define "keymaster.selectorLabels" -}}
app.kubernetes.io/name: {{ include "keymaster.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{/*
Create the name of the service account to use
*/}}
{{- define "keymaster.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "keymaster.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}

@ -1,7 +0,0 @@
kind: ConfigMap
apiVersion: v1
metadata:
name: {{.Release.Name}}-config
data:
keymaster.json: |
{{.Values.keymaster.config | indent 4}}

@ -1,40 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "keymaster.fullname" . }}
labels:
{{- include "keymaster.labels" . | nindent 4 }}
spec:
replicas: 1
selector:
matchLabels:
{{- include "keymaster.selectorLabels" . | nindent 6 }}
template:
metadata:
{{- with .Values.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "keymaster.selectorLabels" . | nindent 8 }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
volumes:
- name: keymaster-config
configMap:
name: {{.Release.Name}}-config
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
ports:
- name: metrics
containerPort: 9090
volumeMounts:
- name: keymaster-config
mountPath: /code/config

@ -1,31 +0,0 @@
replicaCount: 1
image:
repository: gcr.io/clabs-optics/keymaster
pullPolicy: Always
# Overrides the image tag whose default is the chart appVersion.
tag: 0.0.2
keymaster:
config: # Contents of JSON Config File
imagePullSecrets: []
nameOverride: ""
fullnameOverride: ""
podAnnotations:
prometheus.io/scrape: 'true'
prometheus.io/port: '9090'
resources: {}
# We usually recommend not to specify default resources and to leave this as a conscious
# choice for the user. This also increases chances charts run on environments with little
# resources, such as Minikube. If you do want to specify resources, uncomment the following
# lines, adjust them as necessary, and remove the curly braces after 'resources:'.
# limits:
# cpu: 100m
# memory: 128Mi
# requests:
# cpu: 100m
# memory: 128Mi

@ -1,42 +0,0 @@
{
"networks": {
"alfajores": {
"endpoint": "https://alfajores-forno.celo-testnet.org",
"bank": {
"signer": "<hexKey>",
"address": "<address>"
},
"threshold": 500000000000000000
},
"kovan": {
"endpoint": "<RPCEndpoint>",
"bank": {
"signer": "<hexKey>",
"address": "<address>"
},
"threshold": 500000000000000000
}
},
"homes": {
"alfajores": {
"replicas": ["kovan"],
"addresses": {
"kathy": "<address>",
"watcher": "<address>",
"updater": "<address>",
"relayer": "<address>",
"processor": "<address>"
}
},
"kovan": {
"replicas": ["alfajores"],
"addresses": {
"kathy": "<address>",
"watcher": "<address>",
"updater": "<address>",
"relayer": "<address>",
"processor": "<address>"
}
}
}
}

@ -1,206 +0,0 @@
#!/usr/bin/python3
# keymaster.py
# Used to perform agent wallet maintenance like:
# top-up - ensures configured addresses have sufficient funds
from utils import dispatch_signed_transaction
from web3 import Web3
from prometheus_client import start_http_server, Counter, Gauge
from config import load_config
from utils import create_transaction, is_wallet_below_threshold, get_nonce, get_balance, get_block_height
import click
import logging
import json
import sys
import time
@click.group()
@click.option('--debug/--no-debug', default=False)
@click.option('--config-path', default="./config/keymaster.json")
@click.pass_context
def cli(ctx, debug, config_path):
ctx.ensure_object(dict)
ctx.obj['DEBUG'] = debug
conf = load_config(config_path)
if conf:
ctx.obj['CONFIG'] = conf
else:
# Failed to load config, barf
click.echo(f"Failed to load config from {config_path}, check the file and try again.")
sys.exit(1)
# Set up logging
logging.basicConfig(stream=sys.stdout, level=logging.INFO)
if debug:
click.echo(f"Loaded config from {config_path}")
click.echo(json.dumps(ctx.obj['CONFIG'], indent=2))
@cli.command()
@click.pass_context
@click.option('--metrics-port', default=9090, help="Port to bind metrics server to.")
@click.option('--pause-duration', default=30, help="Number of seconds to sleep between polling.")
def monitor(ctx, metrics_port, pause_duration):
"""Simple program that polls one or more ethereum accounts and reports metrics on them."""
# Get config
config = ctx.obj["CONFIG"]
# Set up prometheus metrics
metrics = {
"wallet_balance": Gauge("ethereum_wallet_balance", "ETH Wallet Balance", ["role", "home", "address", "network"]),
"transaction_count": Gauge("ethereum_transaction_count", "ETH Wallet Balance", ["role", "home", "address", "network"]),
"block_number": Gauge("ethereum_block_height", "Block Height", ["network"])
}
# Set up logging
logging.basicConfig(stream=sys.stdout, level=logging.INFO)
# run metrics endpoint
start_http_server(metrics_port)
logging.info(f"Running Prometheus endpoint on port {metrics_port}")
logging.info("Executing event loop, Ctrl+C to exit.")
# main event loop
while True:
# top-up if we see a low balance
should_top_up = False
threshold = 150000000000000000
# for each rpc
for name, network in config["networks"].items():
endpoint = network["endpoint"]
# Fetch block height
try:
block_height = get_block_height(endpoint)
metrics["block_number"].labels(network=name).set(block_height)
except ValueError:
continue
# fetch bank balance
account = network["bank"]["address"]
logging.info(f"Fetching metrics for {account} via {endpoint}")
wallet_wei = get_balance(account, endpoint)
logging.info(f"Wallet Balance: {wallet_wei * 10**-18}")
# fetch tx count
tx_count = get_nonce(account, endpoint)
logging.info(f"Transaction Count: {tx_count}")
# report metrics
metrics["wallet_balance"].labels(role="bank", home=name, address=account, network=name).set(wallet_wei)
metrics["transaction_count"].labels(role="bank", home=name, address=account, network=name).set(tx_count)
# for each account
for home_name, home in config["homes"].items():
for role, account in home["addresses"].items():
logging.info(f"Fetching metrics for {account} via {endpoint}")
# fetch balance
wallet_wei = get_balance(account, endpoint)
logging.info(f"Wallet Balance: {wallet_wei * 10**-18}")
if wallet_wei < threshold:
logging.warn(f"BALANCE IS LOW, MARKING FOR TOP-UP {wallet_wei} < {threshold}")
should_top_up = True
# fetch tx count
tx_count = get_nonce(account, endpoint)
logging.info(f"Transaction Count: {tx_count}")
# report metrics
metrics["wallet_balance"].labels(role=role, home=home_name, address=account, network=name).set(wallet_wei)
metrics["transaction_count"].labels(role=role, home=home_name, address=account, network=name).set(tx_count)
if should_top_up:
_top_up(ctx, auto_approve=True)
logging.info(f"Sleeping for {pause_duration} seconds.")
time.sleep(pause_duration)
@cli.command()
@click.pass_context
def top_up(ctx):
_top_up(ctx)
def _top_up(ctx, auto_approve=False):
click.echo(f"Debug is {'on' if ctx.obj['DEBUG'] else 'off'}")
config = ctx.obj["CONFIG"]
transaction_queue = {}
# Init transaction queue for each network
for network in config["networks"]:
transaction_queue[network] = []
for home in config["homes"]:
for role, address in config["homes"][home]["addresses"].items():
logging.info(f"Processing {role}-{address} on {home}")
# fetch config params
home_upper_bound = config["networks"][home]["threshold"]
# don't top up until balance has gone beneath lower bound
home_lower_bound = 150000000000000000
home_endpoint = config["networks"][home]["endpoint"]
home_bank_signer = config["networks"][home]["bank"]["signer"]
home_bank_address = config["networks"][home]["bank"]["address"]
# check if balance is below threshold at home
threshold_difference = is_wallet_below_threshold(address, home_lower_bound, home_upper_bound, home_endpoint)
# get nonce
home_bank_nonce = get_nonce(home_bank_address, home_endpoint)
if threshold_difference:
logging.info(f"Threshold difference is {threshold_difference} for {role}-{address} on {home}, enqueueing transaction.")
# if so, enqueue top up with (threshold - balance) ether
transaction = create_transaction(home_bank_signer, address, threshold_difference, home_bank_nonce + len(transaction_queue[home]), home_endpoint)
transaction_queue[home].append(transaction)
else:
logging.info(f"Threshold difference is satisfactory for {role}-{address} on {home}, no action.")
for replica in config["homes"][home]["replicas"]:
# fetch config params
replica_upper_bound = config["networks"][replica]["threshold"]
# don't top up until balance has gone beneath lower bound
replica_lower_bound = 150000000000000000
replica_endpoint = config["networks"][replica]["endpoint"]
replica_bank_signer = config["networks"][replica]["bank"]["signer"]
replica_bank_address = config["networks"][replica]["bank"]["address"]
# check if balance is below threshold at replica
threshold_difference = is_wallet_below_threshold(address, replica_lower_bound, replica_upper_bound, replica_endpoint)
# get nonce
replica_bank_nonce = get_nonce(replica_bank_address, replica_endpoint)
# if so, enqueue top up with (threshold - balance) ether
if threshold_difference:
logging.info(f"Threshold difference is {threshold_difference} for {role}-{address} on {replica}, enqueueing transaction.")
transaction = create_transaction(replica_bank_signer, address, threshold_difference, replica_bank_nonce + len(transaction_queue[replica]), replica_endpoint)
transaction_queue[replica].append(transaction)
else:
logging.info(f"Threshold difference is satisfactory for {role}-{address} on {replica}, no action.")
# compute analytics about enqueued transactions
click.echo("\n Transaction Stats:")
for network in transaction_queue:
if len(transaction_queue[network]) > 0:
amount_sum = sum(tx[0]["value"] for tx in transaction_queue[network])
bank_balance = get_balance(config["networks"][network]["bank"]["address"], config["networks"][network]["endpoint"])
click.echo(f"\t {network} Bank has {Web3.fromWei(bank_balance, 'ether')} ETH")
click.echo(f"\t About to send {len(transaction_queue[network])} transactions on {network} - Total of {Web3.fromWei(amount_sum, 'ether')} ETH \n")
if not auto_approve:
click.confirm("Would you like to proceed with dispatching these transactions?", abort=True)
else:
# Send it!!
click.echo("Auto-Approved. Dispatching.")
# Process enqueued transactions
click.echo(f"Processing transactions for {network}")
for transaction_tuple in transaction_queue[network]:
click.echo(f"Attempting to send transaction: {json.dumps(transaction_tuple[0], indent=2, default=str)}")
hash = dispatch_signed_transaction(transaction_tuple[1], config["networks"][network]["endpoint"])
click.echo(f"Dispatched Transaction: {hash}")
time.sleep(3)
else:
click.echo(f"\t No transactions to process for {network}, continuing...")
if __name__ == '__main__':
cli(obj={})

@ -1 +0,0 @@
docker push gcr.io/clabs-optics/keymaster:$1

@ -1,6 +0,0 @@
click==8.0.1
python-json-config==1.2.3
web3==5.22.0
prometheus-client==0.11.0
backoff==1.11.1
websockets>=10.0 # not directly required, pinned by Snyk to avoid a vulnerability

@ -1,81 +0,0 @@
from web3 import Web3
import backoff
# Checks if an address is below the threshold
# returns difference in wei if true
# returns False if not
def is_wallet_below_threshold(address:str, lower_bound:int, upper_bound:int, endpoint:str):
w3 = Web3(Web3.HTTPProvider(endpoint))
address = Web3.toChecksumAddress(address)
# get balance
wallet_wei = get_balance(address, endpoint)
# if balance below lower bound
if wallet_wei < lower_bound:
# return the amount we have to top up
# to reach upper bound
return upper_bound - wallet_wei
else:
return False
# creates a transaction for a sender and recipient
# given a network RPC endpoint
# returns tuple (tx_params, signed_tx) for debugging
def create_transaction(sender_key:str, recipient_address:int, amount:int, nonce:int, endpoint:str):
# Set up w3 provider with network endpoint
w3 = Web3(Web3.HTTPProvider(endpoint))
recipient_address = Web3.toChecksumAddress(recipient_address)
chain_id = w3.eth.chain_id
gas = 100000 * 100 if "arb-rinkeby" in endpoint else 100000
# sign transaction
tx_params = dict(
nonce=nonce,
gasPrice= 500 * 10 ** 9,
gas=gas,
to=recipient_address,
value=amount,
data=b'',
chainId=chain_id,
)
signed_txn = w3.eth.account.sign_transaction(tx_params,sender_key)
return (tx_params, signed_txn)
# gets the current nonce for an address
@backoff.on_exception(backoff.expo,
ValueError,
max_tries=18)
def get_nonce(address:str, endpoint:str):
w3 = Web3(Web3.HTTPProvider(endpoint))
address = Web3.toChecksumAddress(address)
nonce = w3.eth.get_transaction_count(address)
return nonce
# gets the current nonce for an address
@backoff.on_exception(backoff.expo,
ValueError,
max_tries=8)
def get_block_height(endpoint:str):
w3 = Web3(Web3.HTTPProvider(endpoint))
block_height = w3.eth.get_block_number()
return block_height
@backoff.on_exception(backoff.expo,
ValueError,
max_tries=8)
def get_balance(address:str, endpoint:str):
w3 = Web3(Web3.HTTPProvider(endpoint))
address = Web3.toChecksumAddress(address)
wallet_wei = w3.eth.get_balance(address)
return wallet_wei
# dispatches a signed transaction from create_transaction
@backoff.on_exception(backoff.expo,
ValueError,
max_tries=8)
def dispatch_signed_transaction(signed_transaction, endpoint:str):
# Set up w3 provider with network endpoint
w3 = Web3(Web3.HTTPProvider(endpoint))
hash = w3.eth.send_raw_transaction(signed_transaction.rawTransaction)
return hash
Loading…
Cancel
Save