Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions abis/relay_allowlist.json
Original file line number Diff line number Diff line change
@@ -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"}]
3 changes: 2 additions & 1 deletion addresses/holesky.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@
"module_address": "0x4562c3e63c2e586cD1651B958C22F88135aCAd4f",
"accounting_address": "0xc093e53e8F4b55A223c18A2Da6fA00e60DD5EFE1",
"VEBO_address": "0xffDDF7025410412deaa05E3E1cE68FE53208afcb"
}
},
"relay_allowlist_address": "0x2d86C5855581194a386941806E38cA119E50aEA3"
}
3 changes: 2 additions & 1 deletion addresses/mainnet.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@
"module_address": "0xdA7dE2ECdDfccC6c3AF10108Db212ACBBf9EA83F",
"accounting_address": "0x4d72BFF1BeaC69925F8Bd12526a39BAAb069e5Da",
"VEBO_address": "0x0De4Ea0184c2ad0BacA7183356Aea5B8d5Bf5c6e"
}
},
"relay_allowlist_address": "0xF95f069F9AD107938F6ba802a3da87892298610E"
}
5 changes: 4 additions & 1 deletion swarm/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__':
Expand Down Expand Up @@ -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'):
Expand Down
4 changes: 4 additions & 0 deletions swarm/exception.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
75 changes: 75 additions & 0 deletions swarm/relay_check.py
Original file line number Diff line number Diff line change
@@ -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]})")
3 changes: 3 additions & 0 deletions swarm/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading