diff --git a/.env.example b/.env.example index ea5079d..111a104 100644 --- a/.env.example +++ b/.env.example @@ -7,7 +7,7 @@ KEYS_API_URI=URL_TO_KEYS_API LIDO_LOCATOR_ADDRESS=ETHEREUM_ADDRESS EXECUTION_CLIENT_URI=URL_TO_EL_API -# For option when KEYS_SOURCE is 'keys_file' +# For option when KEYS_SOURCE is 'file' # CONSENSUS_CLIENT_URI: URL_TO_CL_API -# KEYS_SOURCE: keys_file +# KEYS_SOURCE: file # KEYS_FILE_PATH: path/to/keys.yml diff --git a/.gitignore b/.gitignore index 8836772..da27d02 100644 --- a/.gitignore +++ b/.gitignore @@ -131,3 +131,6 @@ dmypy.json # IDE .idea/ + +# Docker +.volumes \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index ae056a7..b653fbe 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,11 @@ -FROM python:3.11.3-slim as base +FROM python:3.11.3-slim AS base RUN apt-get update && apt-get install -y --no-install-recommends -qq \ gcc=4:10.2.1-1 \ libffi-dev=3.3-6 \ g++=4:10.2.1-1 \ git=1:2.30.2-1+deb11u2 \ - curl=7.74.0-1.3+deb11u12 \ + curl=7.74.0-1.3+deb11u14 \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* @@ -20,7 +20,7 @@ ENV PYTHONUNBUFFERED=1 \ ENV PATH="$VENV_PATH/bin:$PATH" -FROM base as builder +FROM base AS builder ENV POETRY_VERSION=1.3.2 RUN pip install --no-cache-dir poetry==$POETRY_VERSION @@ -30,7 +30,7 @@ COPY pyproject.toml poetry.lock ./ RUN poetry install --only main --no-root -FROM base as production +FROM base AS production COPY --from=builder $VENV_PATH $VENV_PATH WORKDIR /app @@ -38,8 +38,8 @@ COPY . . RUN apt-get clean && find /var/lib/apt/lists/ -type f -delete && chown -R www-data /app/ -ENV PROMETHEUS_PORT 9000 -ENV HEALTHCHECK_SERVER_PORT 9010 +ENV PROMETHEUS_PORT=9000 +ENV HEALTHCHECK_SERVER_POR=9010 EXPOSE $PROMETHEUS_PORT USER www-data diff --git a/README.md b/README.md index 9ea9d00..1ab45c7 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Currently it supports: > All exits will be handled as unexpected for specified keys 1. Fill `docker/validators/keys.yml` with your values -2. Set `KEYS_SOURCE=keys_file` in `.env` +2. Set `KEYS_SOURCE=file` in `.env` > If you want to use another path, specify it in `KEYS_FILE_PATH` env variable @@ -42,12 +42,12 @@ Currently it supports: * **Required:** false * **Default:** false --- -`KEYS_SOURCE` - Keys source. If `keys_api` - application will fetch keys from Keys API, if `keys_file` - application will fetch keys from `KEYS_FILE_PATH` +`KEYS_SOURCE` - Keys source. If `keys_api` - application will fetch keys from Keys API, if `file` - application will fetch keys from `KEYS_FILE_PATH` * **Required:** false * **Default:** keys_api --- `KEYS_FILE_PATH` - Path to file with keys -* **Required:** if `KEYS_SOURCE` is `keys_file` +* **Required:** if `KEYS_SOURCE` is `file` * **Default:** ./docker/validators/keys.yml --- `CONSENSUS_CLIENT_URI` - Ethereum consensus layer comma separated API urls @@ -139,6 +139,10 @@ Currently it supports: `ALERTMANAGER_REQUEST_SLEEP_BEFORE_RETRY_IN_SECONDS` - Alertmanager request retry timeout in seconds * **Required:** false * **Default:** 1 +--- +`VALID_WITHDRAWAL_ADDRESSES` - A comma-separated list of addresses. Triggers a critical alert if a monitored execution_request contains a source_address matching any of these addresses +* **Required:** false +* **Default:** [] ## Application metrics diff --git a/docker-compose.yml b/docker-compose.yml index 5179525..55056be 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -52,7 +52,7 @@ services: - 9090 alertmanager: - image: prom/alertmanager:latest + image: prom/alertmanager:v0.25.0 container_name: alertmanager restart: unless-stopped networks: diff --git a/src/handlers/consolidation.py b/src/handlers/consolidation.py new file mode 100644 index 0000000..fe5a116 --- /dev/null +++ b/src/handlers/consolidation.py @@ -0,0 +1,42 @@ +import logging + +from unsync import unsync + +from src.alerts.common import CommonAlert +from src.handlers.handler import WatcherHandler +from src.handlers.helpers import beaconchain +from src.metrics.prometheus.duration_meter import duration_meter +from src.providers.consensus.typings import FullBlockInfo + +logger = logging.getLogger() + + +class ConsolidationHandler(WatcherHandler): + @unsync + @duration_meter() + def handle(self, watcher, head: FullBlockInfo): + if not head.message.body.execution_requests or not head.message.body.execution_requests.consolidations: + logger.info({"msg": f"No consolidation requests in block [{head.message.slot}]"}) + return + + slot = head.message.slot + for consolidation in head.message.body.execution_requests.consolidations: + alert, summary = None, "" + if consolidation.source_address in watcher.valid_withdrawal_addresses: + alert = CommonAlert(name="HeadWatcherConsolidationSourceWithdrawalAddress", severity="critical") + summary = "‼️⛔️Validator consolidation was requested from Withdrawal Vault source address" + elif consolidation.source_pubkey in watcher.user_keys: + alert = CommonAlert(name="HeadWatcherConsolidationUserSourcePubkey", severity="info") + summary = "⚠️Consolidation was requested for our validators" + elif consolidation.target_pubkey in watcher.user_keys: + alert = CommonAlert(name="HeadWatcherConsolidationUserTargetPubkey", severity="info") + summary = "⚠️Someone attempts to consolidate their validators to our validators" + # in the future we should check the type of validator WC: + # if it is 0x02 and source_address == WCs of source validator - It's donation! + + if alert: + description = (f"EL consolidation request source_address='{consolidation.source_address}', " + f"source_pubkey={consolidation.source_pubkey}, " + f"target_pubkey='{consolidation.target_pubkey}'\n" + f"Slot: {beaconchain(slot)}") + self.send_alert(watcher, alert.build_body(summary, description)) diff --git a/src/handlers/el_triggered_exit.py b/src/handlers/el_triggered_exit.py new file mode 100644 index 0000000..3c9b864 --- /dev/null +++ b/src/handlers/el_triggered_exit.py @@ -0,0 +1,37 @@ +import logging + +from unsync import unsync + +from src.alerts.common import CommonAlert +from src.handlers.handler import WatcherHandler +from src.handlers.helpers import beaconchain +from src.metrics.prometheus.duration_meter import duration_meter +from src.providers.consensus.typings import FullBlockInfo + +logger = logging.getLogger() + + +class ElTriggeredExitHandler(WatcherHandler): + @unsync + @duration_meter() + def handle(self, watcher, head: FullBlockInfo): + if not head.message.body.execution_requests or not head.message.body.execution_requests.withdrawals: + logger.debug({"msg": f"No withdrawals requests in block [{head.message.slot}]"}) + return + + slot = head.message.slot + for withdrawal in head.message.body.execution_requests.withdrawals: + alert, summary = None, "" + if withdrawal.source_address in watcher.valid_withdrawal_addresses: + alert = CommonAlert(name="HeadWatcherELWithdrawalFromUserWithdrawalAddress", severity="critical") + summary = "🔗‍🏃🚪Our validator triggered withdrawal was requested from our Withdrawal Vault address" + elif withdrawal.validator_pubkey in watcher.user_keys: + alert = CommonAlert(name="HeadWatcherUserELWithdrawal", severity="info") + summary = "🔗‍🏃🚪Our validator triggered withdrawal was requested" + + if alert: + description = (f"EL withdrawals request source_address='{withdrawal.source_address}', " + f"validator_pubkey={withdrawal.validator_pubkey}, " + f"amount='{withdrawal.amount}'\n" + f"Slot: {beaconchain(slot)}") + self.send_alert(watcher, alert.build_body(summary, description)) diff --git a/src/handlers/fork.py b/src/handlers/fork.py index aa8ea48..31325b6 100644 --- a/src/handlers/fork.py +++ b/src/handlers/fork.py @@ -4,11 +4,9 @@ from src.alerts.common import CommonAlert from src.handlers.handler import WatcherHandler +from src.handlers.helpers import beaconchain from src.metrics.prometheus.duration_meter import duration_meter from src.providers.consensus.typings import BlockHeaderResponseData, ChainReorgEvent -from src.variables import NETWORK_NAME - -BEACONCHAIN_URL_TEMPLATE = "[{0}](https://{1}.beaconcha.in/slot/{0})" class ForkHandler(WatcherHandler): @@ -43,7 +41,7 @@ def _send_reorg_alert(self, watcher, chain_reorg: ChainReorgEvent): alert = CommonAlert(name="UnhandledChainReorg", severity="info") links = "\n".join( [ - BEACONCHAIN_URL_TEMPLATE.format(s, NETWORK_NAME) + beaconchain(s) for s in range(int(chain_reorg.slot) - int(chain_reorg.depth), int(chain_reorg.slot) + 1) ] ) @@ -59,5 +57,5 @@ def _send_unhandled_head_alert(self, watcher, head: BlockHeaderResponseData): if diff > 0: additional_msg = f"\nAnd {diff} slot(s) before it" parent_root = head.header.message.parent_root - description = f"Please, check unhandled slot: {BEACONCHAIN_URL_TEMPLATE.format(parent_root, NETWORK_NAME)}{additional_msg}" + description = f"Please, check unhandled slot: {beaconchain(parent_root)}{additional_msg}" self.send_alert(watcher, alert.build_body(summary, description)) diff --git a/src/handlers/helpers.py b/src/handlers/helpers.py new file mode 100644 index 0000000..5b8531b --- /dev/null +++ b/src/handlers/helpers.py @@ -0,0 +1,7 @@ +from src.variables import NETWORK_NAME + +BEACONCHAIN_URL_TEMPLATE = "[{0}](https://{1}.beaconcha.in/slot/{0})" + + +def beaconchain(slot) -> str: + return BEACONCHAIN_URL_TEMPLATE.format(slot, NETWORK_NAME) diff --git a/src/main.py b/src/main.py index e5a792b..d6ec885 100644 --- a/src/main.py +++ b/src/main.py @@ -2,9 +2,11 @@ from web3.middleware import simple_cache_middleware from src import variables +from src.handlers.el_triggered_exit import ElTriggeredExitHandler from src.handlers.exit import ExitsHandler from src.handlers.fork import ForkHandler from src.handlers.slashing import SlashingHandler +from src.handlers.consolidation import ConsolidationHandler from src.keys_source.base_source import SourceType from src.keys_source.file_source import FileSource from src.keys_source.keys_api_source import KeysApiSource @@ -60,6 +62,8 @@ def main(): ForkHandler(), ExitsHandler(), # FinalityHandler(), ??? + ConsolidationHandler(), + ElTriggeredExitHandler() ] Watcher(handlers, keys_source, web3).run() diff --git a/src/providers/consensus/typings.py b/src/providers/consensus/typings.py index ef90935..b7e7f71 100644 --- a/src/providers/consensus/typings.py +++ b/src/providers/consensus/typings.py @@ -74,12 +74,39 @@ class BlockVoluntaryExit(Nested, FromResponse): signature: str +@dataclass +class ConsolidationRequest(FromResponse): + source_address: str + source_pubkey: str + target_pubkey: str + +@dataclass +class WithdrawalRequest(FromResponse): + source_address: str + validator_pubkey: str + amount: str + +@dataclass +class DepositRequest(FromResponse): + pubkey: str + withdrawal_credentials: str + amount: str + signature: str + index: int + +@dataclass +class ExecutionRequests(Nested, FromResponse): + deposits: list[DepositRequest] + withdrawals: list[WithdrawalRequest] + consolidations: list[ConsolidationRequest] + @dataclass class BlockBody(Nested, FromResponse): execution_payload: BlockExecutionPayload voluntary_exits: list[BlockVoluntaryExit] proposer_slashings: list attester_slashings: list + execution_requests: Optional[ExecutionRequests] = None @dataclass diff --git a/src/utils/dataclass.py b/src/utils/dataclass.py index 20d524c..503a0b4 100644 --- a/src/utils/dataclass.py +++ b/src/utils/dataclass.py @@ -1,13 +1,21 @@ import functools from dataclasses import dataclass, fields, is_dataclass from types import GenericAlias -from typing import Callable, Self, Sequence, TypeVar +from typing import Callable, Self, Sequence, TypeVar, get_origin, Union, get_args class DecodeToDataclassException(Exception): pass +def try_extract_underlying_type_from_optional(field): + args = get_args(field) + types = [x for x in args if x != type(None)] + if get_origin(field) is Union and type(None) in args and len(types) == 1: + return types[0] + return None + + @dataclass class Nested: """ @@ -31,6 +39,9 @@ def __post_init__(self): elif is_dataclass(field.type) and not is_dataclass(getattr(self, field.name)): factory = self.__get_dataclass_factory(field.type) setattr(self, field.name, factory(**getattr(self, field.name))) + elif getattr(self, field.name) and (underlying := try_extract_underlying_type_from_optional(field.type)): + factory = self.__get_dataclass_factory(underlying) + setattr(self, field.name, factory(**getattr(self, field.name))) @staticmethod def __get_dataclass_factory(field_type): diff --git a/src/variables.py b/src/variables.py index a64b557..59bae13 100644 --- a/src/variables.py +++ b/src/variables.py @@ -43,6 +43,8 @@ LIDO_LOCATOR_ADDRESS = os.getenv('LIDO_LOCATOR_ADDRESS', '') +VALID_WITHDRAWAL_ADDRESSES = os.getenv('VALID_WITHDRAWAL_ADDRESSES', '').split(',') + # - Metrics - PROMETHEUS_PORT = int(os.getenv('PROMETHEUS_PORT', 9000)) PROMETHEUS_PREFIX = os.getenv("PROMETHEUS_PREFIX", "ethereum_head_watcher") diff --git a/src/watcher.py b/src/watcher.py index 40c1259..3d53d12 100644 --- a/src/watcher.py +++ b/src/watcher.py @@ -53,6 +53,11 @@ def __init__(self, handlers: list[WatcherHandler], keys_source: BaseSource, web3 self.indexed_validators_keys: dict[str, str] = {} self.chain_reorgs: dict[str, ChainReorgEvent] = {} self.handled_headers: list[BlockHeaderResponseData] = [] + self.valid_withdrawal_addresses = set(variables.VALID_WITHDRAWAL_ADDRESSES) + if not self.valid_withdrawal_addresses and self.execution: + self.valid_withdrawal_addresses = { + self.execution.lido_contracts.lido_locator.functions.withdrawalVault().call() + } def run(self, slots_range: Optional[str] = SLOTS_RANGE): def _run(slot_to_handle='head'): @@ -161,11 +166,12 @@ def force_use_fallback_callback(result) -> bool: return False slot = slot or 'head' + current_head = self.consensus.get_block_header( slot, force_use_fallback_callback if slot == 'head' else lambda _: False ) if len(self.handled_headers) > 0 and int(current_head.header.message.slot) == int( - self.handled_headers[-1].header.message.slot + self.handled_headers[-1].header.message.slot ): return None current_block = self.consensus.get_block_details(current_head.root) diff --git a/tests/execution_requests/__init__.py b/tests/execution_requests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/execution_requests/conftest.py b/tests/execution_requests/conftest.py new file mode 100644 index 0000000..014eb26 --- /dev/null +++ b/tests/execution_requests/conftest.py @@ -0,0 +1,36 @@ +import pytest + +from src.keys_source.base_source import NamedKey +from tests.execution_requests.helpers import gen_random_address +from tests.execution_requests.stubs import TestValidator, WatcherStub + + +@pytest.fixture +def user_keys() -> dict[str, NamedKey]: + return {} + + +@pytest.fixture +def validator(): + return TestValidator.random() + + +@pytest.fixture +def user_validator(user_keys): + random_validator = TestValidator.random() + user_keys[random_validator.pubkey] = NamedKey( + key=random_validator.pubkey, operatorName='Test operator', operatorIndex='1', moduleIndex='1' + ) + return random_validator + + +@pytest.fixture +def watcher(user_keys) -> WatcherStub: + return WatcherStub(user_keys=user_keys) + + +@pytest.fixture +def withdrawal_address(watcher: WatcherStub) -> str: + address = gen_random_address() + watcher.valid_withdrawal_addresses.add(address) + return address diff --git a/tests/execution_requests/helpers.py b/tests/execution_requests/helpers.py new file mode 100644 index 0000000..5d6a331 --- /dev/null +++ b/tests/execution_requests/helpers.py @@ -0,0 +1,66 @@ +from secrets import token_hex + +from src.providers.consensus.typings import ( + FullBlockInfo, + BlockHeader, + BlockHeaderMessage, + BlockMessage, + BlockBody, + BlockExecutionPayload, + ExecutionRequests, + WithdrawalRequest, + ConsolidationRequest, +) +from src.typings import StateRoot, BlockRoot + + +def gen_random_pubkey(): + return random_hex(48) + + +def gen_random_address(): + return random_hex(20) + + +def random_hex(length: int) -> str: + return '0x' + token_hex(length) + + +def create_sample_block( + withdrawals: list[WithdrawalRequest] = None, consolidations: list[ConsolidationRequest] = None +) -> FullBlockInfo: + block = FullBlockInfo( + root=BlockRoot('0xa69fd326c1e4a84ac56a9f1e440cdb451fce8c4535e4fabd8447cda15506a8d5'), + canonical=True, + header=BlockHeader( + message=BlockHeaderMessage( + slot='33', + proposer_index='25', + parent_root=BlockRoot('0x924057843cd2718a918a1e354c0eb111b15f471319195ed9eeb45e7bf2dae3a7'), + state_root=StateRoot('0xcc026c107005b9442a26d763409886968cde30a1fbd605e2d9a1c813ddce9062'), + body_root='0x6f01de44a85b4cbe85d1d452de1979630217ce42e2326388152f39bb9d0a3dce', + ), + signature='0x99dde0eb3eaaec71e26e7a614f7eb99c37d7a143edbd55c0b2648dc9f2e754a4e26c3f1320592c2603567cc089a68d5d12f65ec1d9940837dd0d59b05356a0bbc1c3ad51a9546ece8c1233b7398ae3cf1df27c61591bf548b065b68d69bb9450', + ), + message=BlockMessage( + slot='33', + proposer_index='25', + parent_root='0x924057843cd2718a918a1e354c0eb111b15f471319195ed9eeb45e7bf2dae3a7', + state_root=StateRoot('0xcc026c107005b9442a26d763409886968cde30a1fbd605e2d9a1c813ddce9062'), + body=BlockBody( + execution_payload=BlockExecutionPayload(block_number='31'), + voluntary_exits=[], + proposer_slashings=[], + attester_slashings=[], + ), + ), + signature='0x99dde0eb3eaaec71e26e7a614f7eb99c37d7a143edbd55c0b2648dc9f2e754a4e26c3f1320592c2603567cc089a68d5d12f65ec1d9940837dd0d59b05356a0bbc1c3ad51a9546ece8c1233b7398ae3cf1df27c61591bf548b065b68d69bb9450', + ) + if not withdrawals and not consolidations: + return block + + execution_requests = ExecutionRequests( + deposits=[], withdrawals=withdrawals or [], consolidations=consolidations or [] + ) + block.message.body.execution_requests = execution_requests + return block diff --git a/tests/execution_requests/stubs.py b/tests/execution_requests/stubs.py new file mode 100644 index 0000000..9a441fd --- /dev/null +++ b/tests/execution_requests/stubs.py @@ -0,0 +1,43 @@ +from dataclasses import dataclass + +from src.keys_source.base_source import NamedKey +from src.providers.alertmanager.typings import AlertBody +from tests.execution_requests.helpers import gen_random_address, gen_random_pubkey + + +@dataclass +class TestValidator: + pubkey: str + withdrawal_address: str + + @staticmethod + def random(): + return TestValidator(pubkey=gen_random_pubkey(), withdrawal_address=gen_random_address()) + + +class AlertmanagerStub: + sent_alerts: list[AlertBody] + + def __init__(self): + self.sent_alerts = [] + + def send_alerts(self, alerts: list[AlertBody]): + self.sent_alerts.extend(alerts) + + +class WatcherStub: + alertmanager: AlertmanagerStub + user_keys: dict[str, NamedKey] + indexed_validators_keys: dict[str, str] + valid_withdrawal_addresses: set[str] + + def __init__( + self, + user_keys: dict[str, NamedKey] = None, + indexed_validators_keys: dict[str, str] = None, + valid_withdrawal_addresses: set[str] = None, + ): + self.alertmanager = AlertmanagerStub() + self.user_keys = user_keys or {} + self.indexed_validators_keys = indexed_validators_keys or {} + self.valid_withdrawal_addresses = valid_withdrawal_addresses or set() diff --git a/tests/execution_requests/test_consolidations.py b/tests/execution_requests/test_consolidations.py new file mode 100644 index 0000000..8e6543d --- /dev/null +++ b/tests/execution_requests/test_consolidations.py @@ -0,0 +1,114 @@ +from src.handlers.consolidation import ConsolidationHandler +from src.providers.consensus.typings import ConsolidationRequest +from tests.execution_requests.helpers import gen_random_pubkey, create_sample_block, gen_random_address + +from tests.execution_requests.stubs import TestValidator, WatcherStub + + +def test_source_is_valid_withdrawal_address(withdrawal_address: str, watcher: WatcherStub): + random_source_pubkey = gen_random_pubkey() + random_target_pubkey = gen_random_pubkey() + + block = create_sample_block( + consolidations=[ + ConsolidationRequest( + source_address=withdrawal_address, + source_pubkey=random_source_pubkey, + target_pubkey=random_target_pubkey, + ) + ] + ) + handler = ConsolidationHandler() + + task = handler.handle(watcher, block) + task.result() + + assert len(watcher.alertmanager.sent_alerts) == 1 + alert = watcher.alertmanager.sent_alerts[0] + assert alert.labels.alertname.startswith('HeadWatcherConsolidationSourceWithdrawalAddress') + assert alert.labels.severity == 'critical' + assert alert.annotations.summary == "‼️⛔️Validator consolidation was requested from Withdrawal Vault source address" + assert random_source_pubkey in alert.annotations.description + assert random_target_pubkey in alert.annotations.description + assert withdrawal_address in alert.annotations.description + assert block.message.slot in alert.annotations.description + + +def test_consolidate_user_validator(user_validator: TestValidator, watcher: WatcherStub): + random_source_address = gen_random_address() + random_target_pubkey = gen_random_pubkey() + + block = create_sample_block( + consolidations=[ + ConsolidationRequest( + source_address=random_source_address, + source_pubkey=user_validator.pubkey, + target_pubkey=random_target_pubkey, + ) + ] + ) + handler = ConsolidationHandler() + + task = handler.handle(watcher, block) + task.result() + + assert len(watcher.alertmanager.sent_alerts) == 1 + alert = watcher.alertmanager.sent_alerts[0] + assert alert.labels.alertname.startswith('HeadWatcherConsolidationUserSourcePubkey') + assert alert.labels.severity == 'info' + assert alert.annotations.summary == "⚠️Consolidation was requested for our validators" + assert random_source_address in alert.annotations.description + assert random_target_pubkey in alert.annotations.description + assert user_validator.pubkey in alert.annotations.description + assert block.message.slot in alert.annotations.description + + +def test_donation(user_validator: TestValidator, watcher: WatcherStub): + random_source_address = gen_random_address() + random_source_pubkey = gen_random_pubkey() + + block = create_sample_block( + consolidations=[ + ConsolidationRequest( + source_address=random_source_address, + source_pubkey=random_source_pubkey, + target_pubkey=user_validator.pubkey, + ) + ] + ) + handler = ConsolidationHandler() + + task = handler.handle(watcher, block) + task.result() + + assert len(watcher.alertmanager.sent_alerts) == 1 + alert = watcher.alertmanager.sent_alerts[0] + assert alert.labels.alertname.startswith('HeadWatcherConsolidationUserTargetPubkey') + assert alert.labels.severity == 'info' + assert alert.annotations.summary == "⚠️Someone attempts to consolidate their validators to our validators" + assert random_source_address in alert.annotations.description + assert random_source_pubkey in alert.annotations.description + assert user_validator.pubkey in alert.annotations.description + assert block.message.slot in alert.annotations.description + + +def test_absence_of_alerts_on_foreign_validators(watcher: WatcherStub): + random_source_address = gen_random_address() + random_target_pubkey = gen_random_pubkey() + random_source_pubkey = gen_random_pubkey() + + block = create_sample_block( + consolidations=[ + ConsolidationRequest( + source_address=random_source_address, + source_pubkey=random_source_pubkey, + target_pubkey=random_target_pubkey, + ) + ] + ) + handler = ConsolidationHandler() + + task = handler.handle(watcher, block) + task.result() + + assert len(watcher.alertmanager.sent_alerts) == 0 diff --git a/tests/execution_requests/test_withdrawals.py b/tests/execution_requests/test_withdrawals.py new file mode 100644 index 0000000..fce1ce5 --- /dev/null +++ b/tests/execution_requests/test_withdrawals.py @@ -0,0 +1,106 @@ +from src.handlers.el_triggered_exit import ElTriggeredExitHandler +from src.keys_source.base_source import NamedKey +from src.providers.consensus.typings import WithdrawalRequest +from tests.execution_requests.helpers import create_sample_block, gen_random_address +from tests.execution_requests.stubs import WatcherStub, TestValidator + + +def tedt_user_validator(user_validator: TestValidator, watcher: WatcherStub): + random_address = gen_random_address() + block = create_sample_block( + withdrawals=[ + WithdrawalRequest(source_address=random_address, validator_pubkey=user_validator.pubkey, amount='32') + ] + ) + handler = ElTriggeredExitHandler() + + task = handler.handle(watcher, block) + task.result() + + assert len(watcher.alertmanager.sent_alerts) == 1 + alert = watcher.alertmanager.sent_alerts[0] + assert alert.labels.alertname.startswith('HeadWatcherUserELWithdrawal') + assert alert.labels.severity == 'info' + assert alert.annotations.summary == "🔗‍🏃🚪Our validator triggered withdrawal was requested" + assert user_validator.pubkey in alert.annotations.description + assert random_address in alert.annotations.description + assert '32' in alert.annotations.description + assert block.message.slot in alert.annotations.description + + +def test_absence_of_alerts_for_foreign_validator(validator: TestValidator, watcher: WatcherStub): + block = create_sample_block( + withdrawals=[ + WithdrawalRequest(source_address=gen_random_address(), validator_pubkey=validator.pubkey, amount='32') + ] + ) + handler = ElTriggeredExitHandler() + + task = handler.handle(watcher, block) + task.result() + + assert len(watcher.alertmanager.sent_alerts) == 0 + + +def test_from_user_withdrawal_address(validator: TestValidator, withdrawal_address: str, watcher: WatcherStub): + block = create_sample_block( + withdrawals=[ + WithdrawalRequest(source_address=withdrawal_address, validator_pubkey=validator.pubkey, amount='32') + ] + ) + handler = ElTriggeredExitHandler() + + task = handler.handle(watcher, block) + task.result() + + assert len(watcher.alertmanager.sent_alerts) == 1 + alert = watcher.alertmanager.sent_alerts[0] + assert alert.labels.alertname.startswith('HeadWatcherELWithdrawalFromUserWithdrawalAddress') + assert alert.labels.severity == 'critical' + assert ( + alert.annotations.summary + == "🔗‍🏃🚪Our validator triggered withdrawal was requested from our Withdrawal Vault address" + ) + assert validator.pubkey in alert.annotations.description + assert withdrawal_address in alert.annotations.description + assert '32' in alert.annotations.description + assert block.message.slot in alert.annotations.description + + +def test_works_on_dencun(watcher: WatcherStub): + handler = ElTriggeredExitHandler() + block = create_sample_block() + + task = handler.handle(watcher, block) + task.result() + + assert len(watcher.alertmanager.sent_alerts) == 0 + + +def test_absense_of_alerts_for_foreign_validator(): + validator_pubkey = ( + '0x84a687ffdf21a0ad754d0164d1e2c03035613ab76359e7f5cf51ea4a425a6ee026725ec0a0dbd336f7dab759596f0bf8' + ) + amount = "32" + watcher = WatcherStub( + user_keys={ + validator_pubkey: NamedKey( + key=validator_pubkey, operatorName='Test operator', operatorIndex='1', moduleIndex='1' + ) + } + ) + block = create_sample_block( + withdrawals=[ + WithdrawalRequest( + source_address='0x0048281f02e108ec495e48a25d2adb4732df75bf5750c060ff31c864c053d28d', + validator_pubkey='0xaaf6c1251e73fb600624937760fef218aace5b253bf068ed45398aeb29d821e4d2899343ddcbbe37cb3f6cf500dff26c', + amount=amount, + ) + ] + ) + handler = ElTriggeredExitHandler() + + task = handler.handle(watcher, block) + task.result() + + assert len(watcher.alertmanager.sent_alerts) == 0