Conversation
* Add relayer skeleton * Fix lint
* Refactor network validators * Del network validators
* Refactor network validators * Del network validators * Merged validator types * Merged registration endpoints
* Add deposit signature shares * Add validator_type to validators response * Add public keys file * Rename endpoints, add is_deposit_signature_ready * Rework deposit signature from HexStr to BLSSignature * Review fixes
* Track registered public keys * Handle pending deposits, refactor * Exclude raw exit signature from response * Fix env file in CI * Add VALIDATORS_MANAGER_KEY_FILE to env example
* Remove unused settings * Updated README * Fix markdown * Upd gitignore
* Speed up getting last event * Improvements * Review fix
* Add validators manager address to /info endpoint * Fix lint
There was a problem hiding this comment.
Pull request overview
This PR updates the service to support “operator v4” flows by introducing a relayer API for validator registration/funding/withdrawal/consolidation (with validators-manager EIP-712 signing), extending the sidecar → relayer signature-share submission to include deposit + exit signatures, and replacing the old DB/genesis-validator bootstrap with a file-driven public-keys manager tracked in-memory.
Changes:
- Add new Relayer module (schemas, endpoints, typings, validators-manager signer) and related tests/fixtures.
- Update Validators API to accept combined deposit/exit signature shares and expose richer validator metadata to sidecars.
- Remove sqlite-backed network validators persistence + genesis IPFS bootstrap; use a CSV public-keys file and event scanning to track registrations.
Reviewed changes
Copilot reviewed 31 out of 36 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
src/validators/typings.py |
Removes old validator/network-validator dataclasses; keeps oracle shares dataclass. |
src/validators/tasks.py |
Drops genesis validator loader; updates cleanup lifetime setting; scanner now uses AppState manager. |
src/validators/schema.py |
Renames/reshapes request/response models for v4 (deposit+exit shares; richer validators response). |
src/validators/exit_signature.py |
Adds deposit signature validation helper using sw_utils. |
src/validators/execution.py |
Reworks network validator event processing to update PublicKeysManager instead of DB. |
src/validators/endpoints.py |
Replaces old endpoints with GET /validators and POST /signatures for deposit+exit shares. |
src/validators/database.py |
Deletes sqlite CRUD for network validators. |
src/relayer/validators_manager.py |
New EIP-712 signing utilities for validators-manager signatures. |
src/relayer/typings.py |
New Validator dataclass + ValidatorType supporting v1/v2 withdrawal credentials & deposit data root. |
src/relayer/public_keys.py |
New CSV-backed public key loader and registration tracking (consensus + pending deposits + execution logs). |
src/relayer/schema.py |
New request/response schemas for /register, /fund, /withdraw, /consolidate. |
src/relayer/endpoints.py |
New relayer API endpoints that create validators and produce validators-manager signatures. |
src/relayer/tests/test_public_keys.py |
Unit tests for PublicKeysManager behavior. |
src/relayer/tests/test_endpoints.py |
Endpoint-flow tests, including signature-share aggregation. |
src/relayer/tests/conftest.py |
Loads sidecar share fixtures for tests. |
src/relayer/tests/fixtures/sidecar_shares_1_validator.json |
Test fixture for 1-validator signature shares. |
src/relayer/tests/fixtures/sidecar_shares_2_validators.json |
Test fixture for 2-validator signature shares. |
src/conftest.py |
Adds shared httpx test client fixture for FastAPI app. |
src/config/settings.py |
Removes DB/genesis IPFS settings; adds validators-manager/public-keys settings + event concurrency config. |
src/common/schema.py |
Extends /info response with validators-manager address. |
src/common/endpoints.py |
Implements /info validators-manager address output. |
src/common/contracts.py |
Adds concurrency to event scanning and adds Vault/Registry helper methods. |
src/common/clients.py |
Removes sqlite DB client; keeps consensus/execution/ipfs clients. |
src/common/abi/IEthVault.json |
Adds EthVault ABI for VaultContract wrapper. |
src/common/tests/test_endpoints.py |
Adds test coverage for updated /info. |
src/app_state.py |
Adds public_keys_manager + validators_manager_account to global state; switches validator type source. |
src/app.py |
Loads validators-manager account + public keys on startup; registers relayer router; adjusts task startup. |
pyproject.toml |
Bumps version to v1.0.0, adds httpx, configures pytest asyncio mode. |
poetry.lock |
Locks new httpx/httpcore dependencies. |
README.md |
Updates run instructions and sidecar interaction description for v4. |
.env.example |
Replaces DB setting with keyfile/password/public-keys settings and concurrency option. |
.github/workflows/ci.yaml |
Ensures .env exists in CI by copying .env.example. |
.gitignore |
Ignores additional local/dev artifacts. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 32 out of 37 changed files in this pull request and generated 8 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 34 out of 39 changed files in this pull request and generated 6 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| # Build all chunk ranges from newest to oldest | ||
| ranges: list[tuple[BlockNumber, BlockNumber]] = [] | ||
| chunk_to = to_block | ||
| while chunk_to >= from_block: | ||
| chunk_from = BlockNumber(max(chunk_to - blocks_range + 1, from_block)) | ||
| ranges.append((chunk_from, chunk_to)) | ||
| chunk_to = BlockNumber(chunk_to - blocks_range) | ||
|
|
||
| # from_block and to_block are both inclusive | ||
| async def fetch_chunk(chunk_from: BlockNumber, chunk_to: BlockNumber) -> list[EventData]: | ||
| return await event.get_logs( | ||
| from_block=chunk_from, | ||
| to_block=chunk_to, | ||
| argument_filters=argument_filters, | ||
| ) | ||
| if events: | ||
| return events[-1] | ||
| to_block = BlockNumber(to_block - blocks_range - 1) | ||
|
|
||
| # Process chunks in batches (newest-first), abort on first hit | ||
| for batch in itertools.batched(ranges, settings.event_logs_max_concurrency): | ||
| batch_results = await asyncio.gather(*[fetch_chunk(f, t) for f, t in batch]) |
There was a problem hiding this comment.
_get_last_event now builds a full ranges list for the entire [from_block,to_block] span before processing. For very large ranges this can be a substantial, avoidable memory cost; consider generating ranges lazily per batch instead. Also, itertools.batched(..., settings.event_logs_max_concurrency) will raise if the concurrency is <= 0, so validating/clamping that setting would prevent runtime crashes on misconfiguration.
| app_state = AppState() | ||
| encoded_message = encode_typed_data(full_message=full_message) | ||
| signed_msg = app_state.validators_manager_account.sign_message(encoded_message) | ||
|
|
||
| return HexStr(signed_msg.signature.hex()) |
There was a problem hiding this comment.
_create_and_sign_message returns signed_msg.signature.hex() without a 0x prefix, so the API will emit a non-standard hex string (and may fail downstream HexStr parsing/on-chain tooling). Prefix the signature with 0x (e.g., via Web3.to_hex(signed_msg.signature) or manual prefixing).
| # Compute validators manager signature | ||
| validators_manager_signature: HexStr | None = None | ||
|
|
||
| if is_signatures_ready_for_all_validators: | ||
| validators_registry_root = await validators_registry_contract.get_registry_root() | ||
| validators_manager_signature = get_validators_manager_signature_register( |
There was a problem hiding this comment.
If there are zero validators in the /register loop (e.g., no unregistered keys or empty amounts), is_signatures_ready_for_all_validators stays True and the code will generate a validators-manager signature over an empty validators list. Consider explicitly rejecting empty requests/results (or forcing the flag to False) to avoid producing a potentially meaningful signature for an empty payload.
| for public_key, amount in zip(request.public_keys, request.amounts): | ||
| validator = Validator( | ||
| public_key=public_key, | ||
| vault=request.vault, | ||
| amount=amount, | ||
| validator_type=ValidatorType.V2, | ||
| validator_index=0, | ||
| created_at=0, | ||
| deposit_signature=BLSSignature(empty_signature), | ||
| ) | ||
| validators.append(validator) |
There was a problem hiding this comment.
/fund uses zip(request.public_keys, request.amounts), which silently truncates if the list lengths differ. Add an explicit length check and return a 4xx error on mismatch to avoid signing incorrect/partial funding payloads.
| def _encode_withdrawals(public_keys: list[HexStr], amounts: list[Gwei]) -> bytes: | ||
| data = b'' | ||
| for public_key, amount in zip(public_keys, amounts): | ||
| data += Web3.to_bytes(hexstr=public_key) | ||
| data += amount.to_bytes(8, byteorder='big') | ||
|
|
||
| return data | ||
|
|
||
|
|
||
| def _encode_consolidations( | ||
| source_public_keys: list[HexStr], target_public_keys: list[HexStr] | ||
| ) -> bytes: | ||
| validators_data = b'' | ||
| for source_key, target_key in zip(source_public_keys, target_public_keys): | ||
| validators_data += Web3.to_bytes(hexstr=source_key) | ||
| validators_data += Web3.to_bytes(hexstr=target_key) | ||
| return validators_data |
There was a problem hiding this comment.
Both _encode_withdrawals and _encode_consolidations use zip(...), so mismatched list lengths will silently drop trailing items and still produce a signature. It’s safer to validate equal lengths (and non-empty where required) and raise a clear error rather than signing a truncated payload.
| validator.deposit_signature_shares[request.share_index] = BLSSignature( | ||
| Web3.to_bytes(hexstr=share.deposit_signature) | ||
| ) | ||
|
|
||
| return ExitSignatureShareResponse() | ||
| if len(validator.deposit_signature_shares) >= settings.signature_threshold: | ||
| # Reconstruct and validate deposit signature | ||
| deposit_signature = reconstruct_shared_bls_signature( | ||
| validator.deposit_signature_shares | ||
| ) | ||
| if not validate_deposit_signature( | ||
| validator.public_key, | ||
| Web3.to_bytes(hexstr=validator.withdrawal_credentials), | ||
| validator.amount, | ||
| deposit_signature, | ||
| ): | ||
| raise HTTPException( | ||
| status_code=400, | ||
| detail=( | ||
| f'invalid deposit signature for public_key={share.public_key},' | ||
| f' share_index={request.share_index}' | ||
| ), | ||
| ) | ||
|
|
There was a problem hiding this comment.
Same issue for deposit shares: if deposit-signature reconstruction/validation fails, the share is already persisted in validator.deposit_signature_shares, and resubmitting for that share_index will be skipped. Roll back the stored share (or avoid storing until successful) before raising the HTTP 400 to prevent a single bad submission from bricking the validator.
No description provided.