diff --git a/README.md b/README.md index 00a9eaf..060fb6b 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,12 @@ The state check subcommand will retrieve all keys registered in CSM, validator c `--delete` will attempt to remove dangling validator keys, i.e. validator keys that are present either in the validaor client or remote signer, but not in CSM. Keys registered in CSM will never be deleted. +### Relay check + +The relay check subcommand will check if the validator keys are registered with all whitelisted relays in the Lido Relay Allowlist. + +`python -m swarm relay-check` + ### Manual exit This subcommand will submit an exit request for a validator with a given public key. diff --git a/abis/relay_allowlist.json b/abis/relay_allowlist.json new file mode 100644 index 0000000..840b0be --- /dev/null +++ b/abis/relay_allowlist.json @@ -0,0 +1 @@ +[{"name":"RelayAdded","inputs":[{"name":"uri_hash","type":"string","indexed":true},{"name":"relay","type":"tuple","components":[{"name":"uri","type":"string"},{"name":"operator","type":"string"},{"name":"is_mandatory","type":"bool"},{"name":"description","type":"string"}],"indexed":false}],"anonymous":false,"type":"event"},{"name":"RelayRemoved","inputs":[{"name":"uri_hash","type":"string","indexed":true},{"name":"uri","type":"string","indexed":false}],"anonymous":false,"type":"event"},{"name":"AllowedListUpdated","inputs":[{"name":"allowed_list_version","type":"uint256","indexed":true}],"anonymous":false,"type":"event"},{"name":"OwnerChanged","inputs":[{"name":"new_owner","type":"address","indexed":true}],"anonymous":false,"type":"event"},{"name":"ManagerChanged","inputs":[{"name":"new_manager","type":"address","indexed":true}],"anonymous":false,"type":"event"},{"name":"ERC20Recovered","inputs":[{"name":"token","type":"address","indexed":true},{"name":"amount","type":"uint256","indexed":false},{"name":"recipient","type":"address","indexed":true}],"anonymous":false,"type":"event"},{"stateMutability":"nonpayable","type":"constructor","inputs":[{"name":"owner","type":"address"}],"outputs":[]},{"stateMutability":"view","type":"function","name":"get_relays_amount","inputs":[],"outputs":[{"name":"","type":"uint256"}]},{"stateMutability":"view","type":"function","name":"get_owner","inputs":[],"outputs":[{"name":"","type":"address"}]},{"stateMutability":"view","type":"function","name":"get_manager","inputs":[],"outputs":[{"name":"","type":"address"}]},{"stateMutability":"view","type":"function","name":"get_relays","inputs":[],"outputs":[{"name":"","type":"tuple[]","components":[{"name":"uri","type":"string"},{"name":"operator","type":"string"},{"name":"is_mandatory","type":"bool"},{"name":"description","type":"string"}]}]},{"stateMutability":"view","type":"function","name":"get_relay_by_uri","inputs":[{"name":"relay_uri","type":"string"}],"outputs":[{"name":"","type":"tuple","components":[{"name":"uri","type":"string"},{"name":"operator","type":"string"},{"name":"is_mandatory","type":"bool"},{"name":"description","type":"string"}]}]},{"stateMutability":"view","type":"function","name":"get_allowed_list_version","inputs":[],"outputs":[{"name":"","type":"uint256"}]},{"stateMutability":"nonpayable","type":"function","name":"add_relay","inputs":[{"name":"uri","type":"string"},{"name":"operator","type":"string"},{"name":"is_mandatory","type":"bool"},{"name":"description","type":"string"}],"outputs":[]},{"stateMutability":"nonpayable","type":"function","name":"remove_relay","inputs":[{"name":"uri","type":"string"}],"outputs":[]},{"stateMutability":"nonpayable","type":"function","name":"change_owner","inputs":[{"name":"owner","type":"address"}],"outputs":[]},{"stateMutability":"nonpayable","type":"function","name":"set_manager","inputs":[{"name":"manager","type":"address"}],"outputs":[]},{"stateMutability":"nonpayable","type":"function","name":"dismiss_manager","inputs":[],"outputs":[]},{"stateMutability":"nonpayable","type":"function","name":"recover_erc20","inputs":[{"name":"token","type":"address"},{"name":"amount","type":"uint256"},{"name":"recipient","type":"address"}],"outputs":[]},{"stateMutability":"nonpayable","type":"fallback"}] \ No newline at end of file diff --git a/addresses/holesky.json b/addresses/holesky.json index 9326fd8..3895e6b 100644 --- a/addresses/holesky.json +++ b/addresses/holesky.json @@ -4,5 +4,6 @@ "module_address": "0x4562c3e63c2e586cD1651B958C22F88135aCAd4f", "accounting_address": "0xc093e53e8F4b55A223c18A2Da6fA00e60DD5EFE1", "VEBO_address": "0xffDDF7025410412deaa05E3E1cE68FE53208afcb" - } + }, + "relay_allowlist_address": "0x2d86C5855581194a386941806E38cA119E50aEA3" } diff --git a/addresses/mainnet.json b/addresses/mainnet.json index 7e5ba7a..dcc004c 100644 --- a/addresses/mainnet.json +++ b/addresses/mainnet.json @@ -4,5 +4,6 @@ "module_address": "0xdA7dE2ECdDfccC6c3AF10108Db212ACBBf9EA83F", "accounting_address": "0x4d72BFF1BeaC69925F8Bd12526a39BAAb069e5Da", "VEBO_address": "0x0De4Ea0184c2ad0BacA7183356Aea5B8d5Bf5c6e" - } + }, + "relay_allowlist_address": "0xF95f069F9AD107938F6ba802a3da87892298610E" } diff --git a/swarm/__main__.py b/swarm/__main__.py index 91382eb..0312a86 100644 --- a/swarm/__main__.py +++ b/swarm/__main__.py @@ -2,7 +2,7 @@ import toml import sys import asyncio -from . import state_check, deploy, exit, local_sign +from . import state_check, deploy, exit, local_sign, relay_check from .util import load_chain_addresses if __name__ == '__main__': @@ -37,6 +37,9 @@ parser_exit_monitor.add_argument('--telegram', action='store_true', help='send telegram notifications') parser_exit_monitor.set_defaults(func=exit.automated_exit) + parser_relay_check = subparsers.add_parser('relay-check', help='Check validator relay coverage against allowed mev-boost relays') + parser_relay_check.set_defaults(func=relay_check.check_relays) + args = parser.parse_args() if hasattr(args, 'func'): diff --git a/swarm/exception.py b/swarm/exception.py index e559680..1c455ba 100644 --- a/swarm/exception.py +++ b/swarm/exception.py @@ -57,3 +57,7 @@ def __init__(self, *args: object) -> None: class SSHTunnelException(Exception): def __init__(self, *args: object) -> None: super().__init__(*args) + +class RelayRequestException(Exception): + def __init__(self, *args: object) -> None: + super().__init__(*args) diff --git a/swarm/relay_check.py b/swarm/relay_check.py new file mode 100644 index 0000000..3eeacf5 --- /dev/null +++ b/swarm/relay_check.py @@ -0,0 +1,75 @@ +import aiohttp +import asyncio +from .util import load_json_file +import os +from .connection.connection import NodeWSConnection +from .protocol.csm import CSM +from .exception import RelayRequestException + + +async def get_whitelisted_relays(config): + """Fetch whitelisted relays from the allowlist contract""" + async with NodeWSConnection(config['rpc']['execution_address']) as con: + contract = con.get_contract( + address=config['relay_allowlist_address'], + abi=load_json_file(os.path.join(os.getcwd(), 'abis', 'relay_allowlist.json')) + ) + relays = await contract.functions.get_relays().call() + return relays + +async def check_validator_registration(session, relay_url, pubkey): + """Check if a validator is registered with a specific relay""" + url = f"{relay_url}/relay/v1/data/validator_registration" + try: + async with session.get(url, params={'pubkey': pubkey}) as response: + if response.status == 200: + return True + return False + except: + raise RelayRequestException(f"Failed to request validator registration from {relay_url}") + +async def get_validator_keys_from_csm(config): + csm = CSM(config) + id = config['csm']['node_operator_id'] + return await csm.get_registered_keys(id) + +async def check_relays(config, args): + """Main function to check relay coverage for validators""" + print("Checking relay coverage for validators...") + + # Get whitelisted relays + relay_tuples = await get_whitelisted_relays(config) + # Extract just the URLs from the relay tuples + relay_urls = [relay[0] for relay in relay_tuples] + print(f"Found {len(relay_urls)} whitelisted relays") + + # Get validator public keys from CSM + validator_keys = await get_validator_keys_from_csm(config) + print(f"Found {len(validator_keys)} validators in CSM") + + # Check registration for each validator with each relay + async with aiohttp.ClientSession() as session: + results = {} + for validator in validator_keys: + results[validator] = {'total': 0, 'relays': []} + + for relay_url in relay_urls: + is_registered = await check_validator_registration(session, relay_url, validator) + if is_registered: + results[validator]['total'] += 1 + results[validator]['relays'].append(relay_url) + + # Print results + print("\nRelay coverage report:") + print("-" * 50) + for validator, data in results.items(): + coverage_pct = (data['total'] / len(relay_urls)) * 100 + print(f"\nValidator {validator[:12]}...") + print(f"Registered with {data['total']}/{len(relay_urls)} relays ({coverage_pct:.1f}%)") + if data['total'] < len(relay_urls): + print("Missing registrations for relays:") + missing_relays = set(relay_urls) - set(data['relays']) + for relay in missing_relays: + # Find the full relay info for prettier printing + relay_info = next(r for r in relay_tuples if r[0] == relay) + print(f" - {relay} ({relay_info[1]} - {relay_info[3]})") diff --git a/swarm/util.py b/swarm/util.py index 5a9af3c..24d8865 100644 --- a/swarm/util.py +++ b/swarm/util.py @@ -55,4 +55,7 @@ def load_chain_addresses(config: dict) -> dict: # Add withdrawal address config['deposit']['withdrawal_address'] = addresses['withdrawal_address'] + # Add relay allowlist address + config['relay_allowlist_address'] = addresses['relay_allowlist_address'] + return config